feat: 监控面板 / 登录鉴权 / 多平台切换 / MySQL
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

在上游 MediaCrawler 之上新增一层:

- 监控层 api/monitor/ —— 多博主/多笔记的定时采集、指标快照差分、报表、
  企业微信通知。每轮采集写入独立目录,差分才成立。
- WebUI 登录鉴权 api/auth.py —— PBKDF2 口令 + 服务端会话,/api 全接口防护。
  WebSocket 单独加依赖:BaseHTTPMiddleware 对 ws 作用域直接放行,覆盖不到。
- 全局平台切换 + 能力矩阵 —— 如实区分「爬虫模块支持」与「监控层已接线」,
  未接通的平台直接拒绝建任务,而不是静默跑空。
- 监控库改用 MySQL 5.7(可回退 SQLite 供测试):逐表强制 utf8mb4
  (服务端与库默认都是 latin1),启动校验所连 schema 以防写错库,
  连接池 recycle + pre_ping 应对 MySQL 的 8 小时空闲断连。

修复上游缺陷:

- xhs/core.py: 主页抓取失败会跳掉整个博主,导致一条作品都抓不到,
  而那份资料只喂给一个空函数。改为尽力而为,失败不中断。
- xhs/login.py: cookie 登录只注入 web_session,冷启动签名会失败。
  新增 INJECT_ALL_COOKIES 开关(默认关闭,原有行为不变)。
- requirements.txt: 补上 websockets。它在上游 pyproject.toml 里有声明、
  这里漏了,导致 uvicorn 没有 WebSocket 能力,实时日志流从未工作。

改动过的上游文件清单及合并方式见 UPSTREAM.md。

测试:492 passed(另有 1 个既有的 Windows/gbk 上游测试失败,与本改动无关)
This commit is contained in:
2026-10-07 09:58:40 +08:00
parent 5d547f4586
commit 4e60524f37
88 changed files with 13224 additions and 436 deletions
+29
View File
@@ -96,3 +96,32 @@ def sample_xhs_creator():
"interaction": 50000,
"tag_list": '{"profession": "Designer", "interest": "Photography"}'
}
@pytest.fixture(autouse=True)
def _bypass_auth_for_non_auth_suites(request):
"""Skip API authentication for suites that are not about authentication.
Adding auth to every /api route breaks any test that speaks HTTP, so those
suites override the dependency here. This uses FastAPI's own
``dependency_overrides`` mechanism rather than a production-visible
"test mode" switch, which could be shipped enabled by accident.
``tests/test_auth.py`` is deliberately excluded: it must exercise the real
enforcement path, including the route-enumeration guard that asserts every
other /api route really does return 401.
"""
if request.node.fspath.basename == "test_auth.py":
yield
return
from api.auth import require_auth, require_ws_auth
from api.main import app
app.dependency_overrides[require_auth] = lambda: None
app.dependency_overrides[require_ws_auth] = lambda: None
try:
yield
finally:
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(require_ws_auth, None)
+503
View File
@@ -0,0 +1,503 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_auth.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for WebUI authentication.
Deliberately does NOT install ``app.dependency_overrides``: the point of this
file is to exercise the real enforcement path. Other suites override
``require_auth`` so they can keep testing their own concerns.
"""
import asyncio
import time
import httpx
import pytest
import pytest_asyncio
from fastapi import WebSocketException
from sqlalchemy import func, select
from api import auth
from api.main import app
from api.monitor import db as monitor_db
from api.monitor.models import AuthSession
PASSWORD = "correct-horse-battery"
# Captured at import, i.e. before the autouse fixture patches the module global,
# so the guard test below checks the value that actually ships.
REAL_PBKDF2_ITERATIONS = auth.PBKDF2_ITERATIONS
# Every /api route that is allowed to answer without a session.
EXEMPT_PATHS = {"/api/health", "/api/auth/login", "/api/auth/logout"}
@pytest.fixture(autouse=True)
def cheap_hashing(monkeypatch):
"""600k iterations is right in production and unusable in a test suite.
hash_password() resolves the count at call time precisely so this works.
"""
monkeypatch.setattr(auth, "PBKDF2_ITERATIONS", 1_000)
monkeypatch.delenv("MC_PASSWORD", raising=False)
auth.reset_throttle_state()
yield
auth.reset_throttle_state()
@pytest_asyncio.fixture
async def db(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
yield monitor_db
await monitor_db.dispose_engine()
@pytest_asyncio.fixture
async def client(db):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed_password(password: str = PASSWORD) -> None:
async with monitor_db.get_session() as session:
await auth.set_password(session, password)
# --------------------------------------------------------------------------
# Password hashing
# --------------------------------------------------------------------------
class TestPasswordHashing:
def test_iteration_count_has_not_been_lowered(self):
"""Guard: someone trimming this for speed would weaken every install."""
assert REAL_PBKDF2_ITERATIONS >= 600_000
def test_round_trip(self):
stored = auth.hash_password(PASSWORD)
assert auth._verify_password_sync(PASSWORD, stored) is True
def test_wrong_password_rejected(self):
stored = auth.hash_password(PASSWORD)
assert auth._verify_password_sync("wrong", stored) is False
def test_same_password_hashes_differently(self):
"""A fixed salt would let one rainbow table crack every install."""
assert auth.hash_password(PASSWORD) != auth.hash_password(PASSWORD)
def test_format_is_self_describing(self):
algo, iterations, salt, digest = auth.hash_password(PASSWORD).split("$")
assert algo == "pbkdf2_sha256"
assert int(iterations) == auth.PBKDF2_ITERATIONS
assert salt and digest
@pytest.mark.parametrize("stored", ["", "garbage", "md5$1$a$b", "pbkdf2_sha256$x$a$b"])
def test_malformed_stored_hash_is_rejected_not_raised(self, stored):
assert auth._verify_password_sync(PASSWORD, stored) is False
# --------------------------------------------------------------------------
# Credentials
# --------------------------------------------------------------------------
class TestCredentials:
@pytest.mark.asyncio
async def test_check_password_against_stored_hash(self, db):
await _seed_password()
async with monitor_db.get_session() as session:
assert await auth.check_password(session, PASSWORD) is True
assert await auth.check_password(session, "nope") is False
@pytest.mark.asyncio
async def test_no_password_configured_denies_everything(self, db):
"""An unset credential must not mean "open"."""
async with monitor_db.get_session() as session:
assert await auth.check_password(session, "") is False
assert await auth.check_password(session, PASSWORD) is False
@pytest.mark.asyncio
async def test_env_override_wins_and_is_not_persisted(self, db, monkeypatch):
"""The documented way back in after forgetting the password."""
await _seed_password("stored-password")
monkeypatch.setenv("MC_PASSWORD", "env-password")
async with monitor_db.get_session() as session:
assert await auth.check_password(session, "env-password") is True
assert await auth.check_password(session, "stored-password") is False
# Override must never be written to disk.
assert await auth.current_password_hash(session) != ""
assert "env-password" not in (await auth.current_password_hash(session))
@pytest.mark.asyncio
async def test_first_run_generates_a_credential(self, db, monkeypatch):
monkeypatch.delenv("MC_PASSWORD", raising=False)
generated = await auth.ensure_initial_credential()
assert generated
# Second call is a no-op.
assert await auth.ensure_initial_credential() is None
async with monitor_db.get_session() as session:
assert await auth.check_password(session, generated) is True
@pytest.mark.asyncio
async def test_first_run_defers_to_env_password(self, db, monkeypatch):
monkeypatch.setenv("MC_PASSWORD", "env-password")
assert await auth.ensure_initial_credential() is None
async with monitor_db.get_session() as session:
assert await auth.current_password_hash(session) == ""
# --------------------------------------------------------------------------
# Sessions
# --------------------------------------------------------------------------
class TestSessions:
@pytest.mark.asyncio
async def test_round_trip(self, db):
async with monitor_db.get_session() as session:
token, expires_at = await auth.create_session(session)
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, token) is not None
assert expires_at > 0
@pytest.mark.asyncio
async def test_only_the_hash_is_stored(self, db):
"""A database leak must not hand over live sessions."""
async with monitor_db.get_session() as session:
token, _ = await auth.create_session(session)
async with monitor_db.get_session() as session:
stored = (await session.scalars(select(AuthSession.token_hash))).all()
assert token not in stored
assert auth._hash_token(token) in stored
@pytest.mark.asyncio
async def test_expired_session_is_rejected_and_removed(self, db):
async with monitor_db.get_session() as session:
token, _ = await auth.create_session(session)
row = await session.get(AuthSession, auth._hash_token(token))
row.expires_at = 1 # long past
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, token) is None
# Fresh session: the identity map in the one above still holds the
# pending-delete object, so it would answer as if the row were present.
async with monitor_db.get_session() as session:
assert await session.get(AuthSession, auth._hash_token(token)) is None
@pytest.mark.asyncio
async def test_unknown_token_is_rejected(self, db):
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, "never-issued") is None
assert await auth.resolve_session(session, "") is None
@pytest.mark.asyncio
async def test_logout_revokes_only_that_session(self, db):
async with monitor_db.get_session() as session:
first, _ = await auth.create_session(session)
second, _ = await auth.create_session(session)
async with monitor_db.get_session() as session:
await auth.revoke_session(session, first)
async with monitor_db.get_session() as session:
assert await auth.resolve_session(session, first) is None
assert await auth.resolve_session(session, second) is not None
@pytest.mark.asyncio
async def test_revoke_all_clears_every_session(self, db):
async with monitor_db.get_session() as session:
await auth.create_session(session)
await auth.create_session(session)
async with monitor_db.get_session() as session:
removed = await auth.revoke_all_sessions(session)
assert removed == 2
async with monitor_db.get_session() as session:
assert await session.scalar(select(func.count()).select_from(AuthSession)) == 0
# --------------------------------------------------------------------------
# Throttle
# --------------------------------------------------------------------------
class TestThrottle:
@pytest.mark.asyncio
async def test_below_threshold_is_not_throttled(self, db):
key = "1.2.3.4"
for _ in range(auth.THROTTLE_THRESHOLD - 1):
await auth.record_failure(key)
assert await auth.retry_after_seconds(key) == 0
@pytest.mark.asyncio
async def test_lockout_after_repeated_failures(self, db):
key = "1.2.3.4"
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure(key)
assert await auth.retry_after_seconds(key) > 0
@pytest.mark.asyncio
async def test_success_clears_failures(self, db):
key = "1.2.3.4"
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure(key)
await auth.clear_failures(key)
assert await auth.retry_after_seconds(key) == 0
@pytest.mark.asyncio
async def test_failures_age_out_of_the_window(self, db, monkeypatch):
"""Driven by a fake clock rather than sleeping 15 minutes."""
key = "1.2.3.4"
clock = {"now": 1000.0}
monkeypatch.setattr(auth, "_now", lambda: clock["now"])
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure(key)
assert await auth.retry_after_seconds(key) > 0
clock["now"] += auth.THROTTLE_WINDOW_SECONDS + 1
assert await auth.retry_after_seconds(key) == 0
@pytest.mark.asyncio
async def test_keys_are_independent(self, db):
for _ in range(auth.THROTTLE_THRESHOLD):
await auth.record_failure("attacker")
assert await auth.retry_after_seconds("attacker") > 0
assert await auth.retry_after_seconds("innocent") == 0
# --------------------------------------------------------------------------
# HTTP enforcement — the acceptance criteria
# --------------------------------------------------------------------------
class TestEnforcement:
@pytest.mark.asyncio
async def test_health_is_reachable_without_a_session(self, client):
assert (await client.get("/api/health")).status_code == 200
@pytest.mark.asyncio
async def test_protected_endpoint_returns_401_without_a_session(self, client):
response = await client.get("/api/monitor/tasks")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_wrong_password_is_401_and_generic(self, client):
await _seed_password()
response = await client.post("/api/auth/login", json={"password": "wrong"})
assert response.status_code == 401
# Must not reveal whether a password is even configured.
assert response.json()["detail"] == auth.INVALID_CREDENTIALS
@pytest.mark.asyncio
async def test_login_unlocks_the_api(self, client):
await _seed_password()
login = await client.post("/api/auth/login", json={"password": PASSWORD})
assert login.status_code == 200
assert auth.SESSION_COOKIE_NAME in client.cookies
assert (await client.get("/api/monitor/tasks")).status_code == 200
@pytest.mark.asyncio
async def test_cookie_is_httponly_and_lax(self, client):
await _seed_password()
login = await client.post("/api/auth/login", json={"password": PASSWORD})
raw = login.headers["set-cookie"].lower()
assert "httponly" in raw
assert "samesite=lax" in raw
# Secure must be OFF by default: the LAN bind is plain HTTP and a Secure
# cookie is silently dropped there, looping the login page.
assert "secure" not in raw
@pytest.mark.asyncio
async def test_bearer_token_also_works(self, client):
"""Scripts and curl cannot use a cookie jar conveniently."""
await _seed_password()
login = await client.post("/api/auth/login", json={"password": PASSWORD})
token = login.cookies[auth.SESSION_COOKIE_NAME]
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as bare:
bare.headers["Authorization"] = f"Bearer {token}"
assert (await bare.get("/api/monitor/tasks")).status_code == 200
@pytest.mark.asyncio
async def test_tampered_token_is_rejected(self, client):
await _seed_password()
await client.post("/api/auth/login", json={"password": PASSWORD})
client.cookies.set(auth.SESSION_COOKIE_NAME, "not-a-real-token")
assert (await client.get("/api/monitor/tasks")).status_code == 401
@pytest.mark.asyncio
async def test_logout_invalidates_the_session(self, client):
await _seed_password()
await client.post("/api/auth/login", json={"password": PASSWORD})
assert (await client.get("/api/monitor/tasks")).status_code == 200
assert (await client.post("/api/auth/logout")).status_code == 200
assert (await client.get("/api/monitor/tasks")).status_code == 401
@pytest.mark.asyncio
async def test_me_reports_401_when_logged_out(self, client):
await _seed_password()
assert (await client.get("/api/auth/me")).status_code == 401
await client.post("/api/auth/login", json={"password": PASSWORD})
me = await client.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["authenticated"] is True
@pytest.mark.asyncio
async def test_password_change_evicts_other_devices(self, client):
await _seed_password()
# A second "device" holds its own session.
login = await client.post("/api/auth/login", json={"password": PASSWORD})
other_token = login.cookies[auth.SESSION_COOKIE_NAME]
changed = await client.post(
"/api/auth/password",
json={"current": PASSWORD, "new": "brand-new-password"},
)
assert changed.status_code == 200
# The old token is dead.
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as other:
other.cookies.set(auth.SESSION_COOKIE_NAME, other_token)
assert (await other.get("/api/monitor/tasks")).status_code == 401
# ...and the caller is still logged in.
assert (await client.get("/api/monitor/tasks")).status_code == 200
@pytest.mark.asyncio
async def test_password_change_requires_the_current_password(self, client):
await _seed_password()
await client.post("/api/auth/login", json={"password": PASSWORD})
response = await client.post(
"/api/auth/password", json={"current": "wrong", "new": "whatever-new"}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_repeated_failures_get_throttled(self, client):
await _seed_password()
for _ in range(auth.THROTTLE_THRESHOLD):
await client.post("/api/auth/login", json={"password": "wrong"})
blocked = await client.post("/api/auth/login", json={"password": PASSWORD})
assert blocked.status_code == 429
assert "retry-after" in {k.lower() for k in blocked.headers}
@pytest.mark.asyncio
async def test_docs_are_not_exposed(self, client):
for path in ("/docs", "/redoc", "/openapi.json"):
assert (await client.get(path)).status_code == 404
class TestEveryRouteIsGuarded:
@pytest.mark.asyncio
async def test_no_api_route_is_accidentally_open(self, client):
"""The guard that stops the next endpoint from shipping unauthenticated."""
unguarded = []
for route in app.routes:
path = getattr(route, "path", "")
methods = getattr(route, "methods", None)
if not path.startswith("/api") or not methods or path in EXEMPT_PATHS:
continue
# Substitute dummy values for path params so we reach the auth check
# rather than a 404/422 on the parameter itself.
concrete = "/".join(
"1" if segment.startswith("{") else segment for segment in path.split("/")
)
for method in methods - {"HEAD", "OPTIONS"}:
response = await client.request(method, concrete, json={})
if response.status_code != 401:
unguarded.append(f"{method} {path} -> {response.status_code}")
assert not unguarded, f"以下 /api 路由未受鉴权保护:{unguarded}"
# --------------------------------------------------------------------------
# WebSocket enforcement
# --------------------------------------------------------------------------
class _FakeWebSocket:
"""Only `.cookies` is read by require_ws_auth."""
def __init__(self, cookies):
self.cookies = cookies
class TestWebSocketAuth:
"""Guarding websockets needs its own mechanism: BaseHTTPMiddleware returns
early for non-http scopes, and HTTP router dependencies never run for them.
Without this the live crawl log stream would be wide open.
"""
@pytest.mark.asyncio
async def test_missing_cookie_is_rejected(self, db):
with pytest.raises(WebSocketException) as excinfo:
await auth.require_ws_auth(_FakeWebSocket({}))
assert excinfo.value.code == 1008
@pytest.mark.asyncio
async def test_valid_cookie_is_accepted(self, db):
async with monitor_db.get_session() as session:
token, _ = await auth.create_session(session)
# No exception means accepted.
await auth.require_ws_auth(_FakeWebSocket({auth.SESSION_COOKIE_NAME: token}))
@pytest.mark.asyncio
async def test_unknown_cookie_is_rejected(self, db):
with pytest.raises(WebSocketException):
await auth.require_ws_auth(_FakeWebSocket({auth.SESSION_COOKIE_NAME: "bogus"}))
def test_every_websocket_route_carries_the_guard(self):
guarded = {
route.path
for route in app.routes
if route.__class__.__name__ == "APIWebSocketRoute"
and any(
getattr(dep.dependency, "__name__", "") == "require_ws_auth"
for dep in (route.dependencies or [])
)
}
every_ws = {
route.path
for route in app.routes
if route.__class__.__name__ == "APIWebSocketRoute"
}
assert every_ws, "expected at least one websocket route"
assert every_ws == guarded
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_cmd_arg_monitor_flags.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for the CLI flags added to support unattended monitoring runs.
Each flag must default to the existing config value, so a manual crawl that does
not pass them behaves exactly as before.
"""
import pytest
import config
from cmd_arg.arg import parse_cmd
BASE_ARGS = ["--platform", "xhs", "--type", "creator", "--creator_id", "abc123"]
@pytest.fixture(autouse=True)
def _isolate_config(monkeypatch):
monkeypatch.setattr(config, "ENABLE_CDP_MODE", True)
monkeypatch.setattr(config, "INJECT_ALL_COOKIES", False)
monkeypatch.setattr(config, "SAVE_LOGIN_STATE", True)
monkeypatch.setattr(config, "COOKIES", "")
monkeypatch.setattr(config, "SAVE_DATA_PATH", "")
monkeypatch.setattr(config, "CRAWLER_MAX_SLEEP_SEC", 2)
yield
class TestEnableCdpMode:
"""CDP attaches to the user's desktop Chrome, which cannot work on a server."""
@pytest.mark.asyncio
async def test_false_disables_cdp(self):
await parse_cmd([*BASE_ARGS, "--enable_cdp_mode", "false"])
assert config.ENABLE_CDP_MODE is False
@pytest.mark.asyncio
async def test_defaults_to_config_value(self):
await parse_cmd(BASE_ARGS)
assert config.ENABLE_CDP_MODE is True
class TestCookieFlags:
@pytest.mark.asyncio
async def test_inject_all_cookies_enables_switch(self):
await parse_cmd([*BASE_ARGS, "--inject_all_cookies", "true"])
assert config.INJECT_ALL_COOKIES is True
@pytest.mark.asyncio
async def test_inject_all_cookies_defaults_off(self):
await parse_cmd(BASE_ARGS)
assert config.INJECT_ALL_COOKIES is False
@pytest.mark.asyncio
async def test_cookies_file_is_read_into_config(self, tmp_path):
cookie_file = tmp_path / "cookies.txt"
cookie_file.write_text("web_session=abc; a1=def", encoding="utf-8")
await parse_cmd([*BASE_ARGS, "--cookies_file", str(cookie_file)])
assert config.COOKIES == "web_session=abc; a1=def"
@pytest.mark.asyncio
async def test_cookies_file_wins_over_inline_cookies(self, tmp_path):
cookie_file = tmp_path / "cookies.txt"
cookie_file.write_text("web_session=fromfile", encoding="utf-8")
await parse_cmd(
[*BASE_ARGS, "--cookies", "web_session=inline", "--cookies_file", str(cookie_file)]
)
assert config.COOKIES == "web_session=fromfile"
@pytest.mark.asyncio
async def test_missing_cookies_file_is_rejected(self, tmp_path):
missing = tmp_path / "nope.txt"
with pytest.raises(Exception) as excinfo:
await parse_cmd([*BASE_ARGS, "--cookies_file", str(missing)])
# A silently-ignored unreadable cookie file would produce a crawl that
# returns nothing, which is exactly the failure mode this flag exists
# to avoid.
assert "cookies_file" in str(excinfo.value)
class TestSaveDataPath:
@pytest.mark.asyncio
async def test_save_data_path_is_applied(self):
await parse_cmd([*BASE_ARGS, "--save_data_path", "data/monitor_runs/1/2"])
assert config.SAVE_DATA_PATH == "data/monitor_runs/1/2"
class TestSaveLoginState:
@pytest.mark.asyncio
async def test_save_login_state_can_be_disabled(self):
await parse_cmd([*BASE_ARGS, "--save_login_state", "false"])
assert config.SAVE_LOGIN_STATE is False
class TestCrawlSleepSec:
"""Exposed on the Settings page; previously had no CLI flag at all."""
@pytest.mark.asyncio
async def test_value_is_applied(self):
await parse_cmd([*BASE_ARGS, "--crawler_max_sleep_sec", "9"])
assert config.CRAWLER_MAX_SLEEP_SEC == 9
@pytest.mark.asyncio
async def test_defaults_to_config_value(self, monkeypatch):
monkeypatch.setattr(config, "CRAWLER_MAX_SLEEP_SEC", 4)
await parse_cmd(BASE_ARGS)
assert config.CRAWLER_MAX_SLEEP_SEC == 4
+67
View File
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_interpreter.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for the subprocess interpreter resolver."""
import sys
from pathlib import Path
from api.services.interpreter import (
describe_interpreter,
resolve_python_cmd,
venv_python_path,
)
def _make_venv(root: Path) -> Path:
"""Create a fake venv layout and return the expected python path."""
exe = venv_python_path(root)
exe.parent.mkdir(parents=True, exist_ok=True)
exe.write_text("", encoding="utf-8")
return exe
def test_prefers_uv_when_available(monkeypatch, tmp_path):
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
_make_venv(tmp_path)
# uv wins even when a venv exists, matching the upstream documented workflow.
assert resolve_python_cmd(tmp_path) == ["uv", "run", "python"]
def test_falls_back_to_project_venv(monkeypatch, tmp_path):
monkeypatch.setattr("shutil.which", lambda name: None)
exe = _make_venv(tmp_path)
assert resolve_python_cmd(tmp_path) == [str(exe)]
def test_falls_back_to_current_interpreter(monkeypatch, tmp_path):
monkeypatch.setattr("shutil.which", lambda name: None)
# No uv, no venv anywhere under the given root.
assert resolve_python_cmd(tmp_path) == [sys.executable]
def test_describe_is_human_readable(monkeypatch, tmp_path):
monkeypatch.setattr("shutil.which", lambda name: None)
_make_venv(tmp_path)
assert "virtualenv" in describe_interpreter(tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
assert describe_interpreter(tmp_path) == "uv run python"
+208
View File
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_api.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""API-level tests for the monitoring endpoints.
Run against an ASGI transport with a temporary database, so no server, network
or login is required. Lifespan is deliberately not exercised: it would start the
scheduler, and these tests only cover routing, validation and persistence.
"""
import httpx
import pytest
import pytest_asyncio
from api.main import app
from api.monitor import db as monitor_db
from api.monitor.service import TargetParseError, parse_target_input
CREATOR_URL = (
"https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753"
"?xsec_token=ABYVg1evluJZZzpMX-VWzchxQ1qSNVW3r-jOEnKqMcgZw=&xsec_source=pc_search"
)
NOTE_URL = "https://www.xiaohongshu.com/explore/6aa3d827000000002802c5c8?xsec_token=TOKEN&xsec_source=pc_search"
@pytest_asyncio.fixture
async def client(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
await monitor_db.dispose_engine()
class TestParseTargetInput:
def test_full_url_splits_id_from_token(self):
"""The id is the stable key; the token is a refreshable credential."""
parsed = parse_target_input(CREATOR_URL, "creator")
assert parsed["external_id"] == "5f58bd990000000001003753"
assert parsed["xsec_token"].startswith("ABYVg1evluJZZzpMX")
assert parsed["xsec_source"] == "pc_search"
def test_bare_id_is_accepted(self):
parsed = parse_target_input("5f58bd990000000001003753", "creator")
assert parsed["external_id"] == "5f58bd990000000001003753"
assert parsed["xsec_token"] == ""
def test_note_url_without_token_still_parses(self):
parsed = parse_target_input(
"https://www.xiaohongshu.com/explore/6aa3d827000000002802c5c8", "note"
)
assert parsed["external_id"] == "6aa3d827000000002802c5c8"
assert parsed["xsec_token"] == ""
def test_creator_url_rejected_in_note_mode(self):
with pytest.raises(TargetParseError):
parse_target_input(CREATOR_URL, "note")
def test_garbage_is_rejected(self):
with pytest.raises(TargetParseError):
parse_target_input("not a url at all !!", "creator")
class TestTaskCrud:
@pytest.mark.asyncio
async def test_create_and_list_task(self, client):
response = await client.post(
"/api/monitor/tasks",
json={
"name": "网文作者监控",
"mode": "creator",
"interval_minutes": 120,
"targets": [CREATOR_URL, "5f58bd990000000001003754"],
},
)
assert response.status_code == 201
task_id = response.json()["id"]
listing = await client.get("/api/monitor/tasks")
assert listing.status_code == 200
tasks = listing.json()["tasks"]
assert len(tasks) == 1
assert tasks[0]["id"] == task_id
assert tasks[0]["target_count"] == 2
# next_run_at is persisted so the schedule survives a restart.
assert tasks[0]["next_run_at"] is not None
@pytest.mark.asyncio
async def test_duplicate_targets_are_deduplicated(self, client):
response = await client.post(
"/api/monitor/tasks",
json={
"name": "dedup",
"mode": "creator",
"targets": [CREATOR_URL, CREATOR_URL],
},
)
assert response.status_code == 201
listing = await client.get("/api/monitor/tasks")
assert listing.json()["tasks"][0]["target_count"] == 1
@pytest.mark.asyncio
async def test_invalid_target_returns_400(self, client):
response = await client.post(
"/api/monitor/tasks",
json={"name": "bad", "mode": "creator", "targets": ["!!! nonsense !!!"]},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_interval_floor_is_enforced(self, client):
"""A tight poll loop is the pattern that triggers platform rate limits."""
response = await client.post(
"/api/monitor/tasks",
json={"name": "too fast", "mode": "creator", "interval_minutes": 1, "targets": [CREATOR_URL]},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_and_delete(self, client):
created = await client.post(
"/api/monitor/tasks",
json={"name": "t", "mode": "note", "targets": [NOTE_URL]},
)
task_id = created.json()["id"]
patched = await client.patch(f"/api/monitor/tasks/{task_id}", json={"enabled": False})
assert patched.status_code == 200
listing = await client.get("/api/monitor/tasks")
assert listing.json()["tasks"][0]["enabled"] is False
deleted = await client.delete(f"/api/monitor/tasks/{task_id}")
assert deleted.status_code == 200
assert (await client.get("/api/monitor/tasks")).json()["tasks"] == []
@pytest.mark.asyncio
async def test_run_now_on_missing_task_is_404(self, client):
response = await client.post("/api/monitor/tasks/9999/run")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_run_history_starts_empty(self, client):
created = await client.post(
"/api/monitor/tasks",
json={"name": "t", "mode": "creator", "targets": [CREATOR_URL]},
)
task_id = created.json()["id"]
runs = await client.get(f"/api/monitor/tasks/{task_id}/runs")
assert runs.status_code == 200
assert runs.json()["runs"] == []
class TestCookieEndpoints:
@pytest.mark.asyncio
async def test_cookie_value_is_never_returned(self, client):
"""The GET must expose health only, never the credential."""
secret = "web_session=SUPERSECRETVALUE; a1=abc123"
saved = await client.post("/api/monitor/cookie", json={"cookie": secret})
assert saved.status_code == 200
status_response = await client.get("/api/monitor/cookie")
assert status_response.status_code == 200
body = status_response.json()
assert body["present"] is True
assert body["length"] == len(secret)
assert "SUPERSECRETVALUE" not in status_response.text
@pytest.mark.asyncio
async def test_cookie_initially_absent_and_clearable(self, client):
assert (await client.get("/api/monitor/cookie")).json()["present"] is False
await client.post("/api/monitor/cookie", json={"cookie": "web_session=x"})
assert (await client.get("/api/monitor/cookie")).json()["present"] is True
await client.delete("/api/monitor/cookie")
assert (await client.get("/api/monitor/cookie")).json()["present"] is False
class TestDashboardQueries:
@pytest.mark.asyncio
async def test_empty_dashboard_shapes(self, client):
assert (await client.get("/api/monitor/notes")).json()["notes"] == []
assert (await client.get("/api/monitor/comments")).json()["comments"] == []
assert (await client.get("/api/monitor/events")).json()["events"] == []
overview = (await client.get("/api/monitor/overview")).json()
assert overview["tasks"] == 0
assert overview["notes"] == 0
+232
View File
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_comments.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Comment note-association, grouping, and the export endpoint."""
import csv
import io
import httpx
import pytest
import pytest_asyncio
from api.main import app
from api.monitor import db as monitor_db
from api.monitor.models import (
MODE_CREATOR,
MonitorComment,
MonitorNote,
MonitorTask,
)
TASK_NAME = "评论归属测试"
async def _seed():
"""Two works; three comments on the first, one on the second."""
async with monitor_db.get_session() as session:
task = MonitorTask(
name=TASK_NAME, platform="xhs", mode=MODE_CREATOR, enabled=True,
interval_minutes=60, max_notes_count=20, enable_comments=True,
max_comments_count=50, run_timeout_seconds=3600,
notify_enabled=False, created_at=0, updated_at=0,
)
session.add(task)
await session.flush()
for note_id, title in (("note-a", "作品甲"), ("note-b", "作品乙")):
session.add(
MonitorNote(
task_id=task.id, note_id=note_id, title=title,
note_url=f"https://www.xiaohongshu.com/explore/{note_id}",
cover=f"https://img/{note_id}.jpg", creator_hash="h",
source_kind="video", published_at=None,
first_seen_run_id=1, first_seen_at=1_700_000_000_000,
last_seen_run_id=1, last_seen_at=1_700_000_000_000,
)
)
# note-a has three comments, note-b has one.
plan = [
("c1", "note-a", 1_700_000_001_000),
("c2", "note-a", 1_700_000_002_000),
("c3", "note-a", 1_700_000_003_000),
("c4", "note-b", 1_700_000_004_000),
]
for comment_id, note_id, seen_at in plan:
session.add(
MonitorComment(
task_id=task.id, note_id=note_id, comment_id=comment_id,
content=f"内容-{comment_id}", nickname="u***r", creator_hash="h",
create_time=seen_at, like_count=1, sub_comment_count=0,
parent_comment_id="", first_seen_run_id=1, first_seen_at=seen_at,
)
)
return task.id
@pytest_asyncio.fixture
async def client(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
await _seed()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
await monitor_db.dispose_engine()
class TestCommentsCarryTheirNote:
@pytest.mark.asyncio
async def test_each_comment_names_its_work(self, client):
"""A bare note_id is unreadable -- the title is the whole point."""
response = await client.get("/api/monitor/comments")
assert response.status_code == 200
comments = response.json()["comments"]
assert len(comments) == 4
by_id = {c["comment_id"]: c for c in comments}
assert by_id["c1"]["note_title"] == "作品甲"
assert by_id["c1"]["note_url"].endswith("note-a")
assert by_id["c1"]["note_cover"].endswith("note-a.jpg")
assert by_id["c4"]["note_title"] == "作品乙"
@pytest.mark.asyncio
async def test_note_id_filters_the_stream(self, client):
response = await client.get("/api/monitor/comments", params={"note_id": "note-a"})
comments = response.json()["comments"]
assert {c["comment_id"] for c in comments} == {"c1", "c2", "c3"}
class TestGroupByNote:
@pytest.mark.asyncio
async def test_groups_bucket_by_work(self, client):
response = await client.get("/api/monitor/comments", params={"group_by": "note"})
body = response.json()
assert "groups" in body
assert body["total"] == 4
groups = {g["note_id"]: g for g in body["groups"]}
assert set(groups) == {"note-a", "note-b"}
assert len(groups["note-a"]["comments"]) == 3
assert len(groups["note-b"]["comments"]) == 1
assert groups["note-a"]["note_title"] == "作品甲"
@pytest.mark.asyncio
async def test_newest_group_comes_first(self, client):
"""The UI expands the first group by default, so it must be the newest."""
response = await client.get("/api/monitor/comments", params={"group_by": "note"})
groups = response.json()["groups"]
# note-b's only comment is the most recent overall.
assert groups[0]["note_id"] == "note-b"
@pytest.mark.asyncio
async def test_flat_shape_is_unchanged_without_the_flag(self, client):
body = (await client.get("/api/monitor/comments")).json()
assert "comments" in body and "groups" not in body
class TestCommentNoteFilterOptions:
@pytest.mark.asyncio
async def test_options_carry_counts_and_titles(self, client):
response = await client.get("/api/monitor/comment-notes")
assert response.status_code == 200
notes = {n["note_id"]: n for n in response.json()["notes"]}
assert notes["note-a"]["comment_count"] == 3
assert notes["note-b"]["comment_count"] == 1
assert notes["note-a"]["note_title"] == "作品甲"
@pytest.mark.asyncio
async def test_scoped_to_a_task(self, client):
tasks = (await client.get("/api/monitor/tasks")).json()["tasks"]
task_id = tasks[0]["id"]
scoped = await client.get("/api/monitor/comment-notes", params={"task_id": task_id})
assert len(scoped.json()["notes"]) == 2
# A task with no comments yields an empty list, not an error.
other = await client.get("/api/monitor/comment-notes", params={"task_id": 9999})
assert other.json()["notes"] == []
class TestExport:
@pytest.mark.asyncio
async def test_csv_has_a_bom_so_excel_does_not_mangle_chinese(self, client):
response = await client.get("/api/monitor/export", params={"kind": "comments"})
assert response.status_code == 200
assert response.content.startswith(b"\xef\xbb\xbf")
assert "attachment" in response.headers["content-disposition"]
text = response.content.decode("utf-8-sig")
rows = list(csv.DictReader(io.StringIO(text)))
assert len(rows) == 4
assert rows[0]["所属作品"] in ("作品甲", "作品乙")
@pytest.mark.asyncio
async def test_notes_export(self, client):
response = await client.get(
"/api/monitor/export", params={"kind": "notes", "format": "csv"}
)
rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
assert {r["作品ID"] for r in rows} == {"note-a", "note-b"}
@pytest.mark.asyncio
async def test_xlsx_is_a_readable_workbook(self, client):
from openpyxl import load_workbook
response = await client.get(
"/api/monitor/export", params={"kind": "comments", "format": "xlsx"}
)
assert response.status_code == 200
workbook = load_workbook(io.BytesIO(response.content))
sheet = workbook.active
assert sheet.max_row == 5 # header + four comments
assert sheet.cell(row=1, column=1).value == "所属作品"
@pytest.mark.asyncio
async def test_report_export(self, client):
response = await client.get(
"/api/monitor/export",
params={"kind": "report", "days": 3},
)
rows = list(csv.DictReader(io.StringIO(response.content.decode("utf-8-sig"))))
assert len(rows) == 3
assert "日期" in rows[0]
@pytest.mark.asyncio
async def test_unknown_kind_and_format_are_rejected(self, client):
assert (
await client.get("/api/monitor/export", params={"kind": "nope"})
).status_code == 400
assert (
await client.get("/api/monitor/export", params={"kind": "notes", "format": "pdf"})
).status_code == 400
@pytest.mark.asyncio
async def test_empty_selection_is_a_404_not_an_empty_file(self, client):
"""An empty download looks like a bug; say so instead."""
response = await client.get(
"/api/monitor/export", params={"kind": "comments", "note_id": "no-such-note"}
)
assert response.status_code == 404
+527
View File
@@ -0,0 +1,527 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_ingest.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Offline tests for the monitoring ingest/diff layer.
These run without network, browser or login and cover the correctness caveats
that matter most: baseline suppression, count parsing, NULL-vs-zero, the
posted/seen comment split, idempotency, and the silent-cookie-failure signal.
"""
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from tools.time_util import get_current_timestamp
from api.monitor.ingest import describe_exit_code, ingest_run, parse_count
from api.monitor.models import (
EVENT_AUTH_FAILURE,
EVENT_METRIC_DELTA,
EVENT_NEW_COMMENT_POSTED,
EVENT_NEW_COMMENT_SEEN,
EVENT_NEW_NOTE,
EVENT_NO_DATA,
EVENT_RUN_FAILED,
MODE_CREATOR,
MonitorBase,
MonitorEvent,
MonitorNote,
MonitorNoteMetric,
MonitorRun,
MonitorTask,
RUN_FAILED,
RUN_PARTIAL,
RUN_SUCCESS,
)
@pytest_asyncio.fixture
async def db():
"""An isolated in-memory monitoring database."""
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
async with engine.begin() as conn:
await conn.run_sync(MonitorBase.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as db_session:
yield db_session
await engine.dispose()
async def _make_task(db: AsyncSession, **overrides) -> MonitorTask:
defaults = dict(
name="test task",
platform="xhs",
mode=MODE_CREATOR,
enabled=True,
interval_minutes=60,
max_notes_count=20,
enable_comments=True,
max_comments_count=50,
run_timeout_seconds=3600,
created_at=0,
updated_at=0,
)
defaults.update(overrides)
task = MonitorTask(**defaults)
db.add(task)
await db.flush()
return task
async def _make_run(
db: AsyncSession,
task: MonitorTask,
started_at: int,
exit_code: Optional[int] = 0,
) -> MonitorRun:
run = MonitorRun(
task_id=task.id,
trigger="manual",
status=RUN_SUCCESS,
phase=task.mode,
save_data_path="",
queued_at=started_at,
not_before=0,
started_at=started_at,
exit_code=exit_code,
)
db.add(run)
await db.flush()
return run
def _write_run_dir(
root: Path,
notes: List[Dict[str, Any]],
comments: Optional[List[Dict[str, Any]]] = None,
) -> Path:
"""Write a run's jsonl output in the crawler's own layout."""
jsonl_dir = root / "xhs" / "jsonl"
jsonl_dir.mkdir(parents=True, exist_ok=True)
contents = jsonl_dir / "creator_contents_2026-01-01.jsonl"
contents.write_text(
"\n".join(json.dumps(n, ensure_ascii=False) for n in notes),
encoding="utf-8",
)
if comments is not None:
comment_file = jsonl_dir / "creator_comments_2026-01-01.jsonl"
comment_file.write_text(
"\n".join(json.dumps(c, ensure_ascii=False) for c in comments),
encoding="utf-8",
)
return root
def _note(note_id: str, liked: Any = "10", **extra) -> Dict[str, Any]:
record = {
"note_id": note_id,
"title": f"title-{note_id}",
"note_url": f"https://www.xiaohongshu.com/explore/{note_id}",
"image_list": "https://img/cover.jpg",
"creator_hash": "hash",
"time": 1700000000000,
"liked_count": liked,
"comment_count": "1",
"collected_count": "1",
"share_count": "1",
}
record.update(extra)
return record
def _comment(comment_id: str, note_id: str, create_time: int, **extra) -> Dict[str, Any]:
record = {
"comment_id": comment_id,
"note_id": note_id,
"content": f"content-{comment_id}",
"nickname": "u***r",
"creator_hash": "hash",
"create_time": create_time,
"like_count": "0",
"sub_comment_count": 0,
"parent_comment_id": "",
}
record.update(extra)
return record
async def _events(db: AsyncSession, event_type: Optional[str] = None) -> List[MonitorEvent]:
stmt = select(MonitorEvent)
if event_type:
stmt = stmt.where(MonitorEvent.type == event_type)
return list((await db.scalars(stmt)).all())
# --------------------------------------------------------------------------
# parse_count
# --------------------------------------------------------------------------
class TestParseCount:
@pytest.mark.parametrize(
"raw,expected",
[
("1234", 1234),
("1.2万", 12000),
("1.2w", 12000),
("3亿", 300000000),
("1,234", 1234),
(42, 42),
],
)
def test_parses_platform_formats(self, raw, expected):
assert parse_count(raw) == expected
@pytest.mark.parametrize("raw", ["", None, "暂无", "-", "abc", True])
def test_unparseable_values_return_none(self, raw):
assert parse_count(raw) is None
# --------------------------------------------------------------------------
# Exit codes
# --------------------------------------------------------------------------
class TestExitCodeStorage:
"""Guards a bug that only showed up when the data moved to MySQL.
Windows reports process failures as unsigned 32-bit NTSTATUS values
(0xC0000142 = 3221225794). That overflows MySQL's signed INT, while SQLite's
dynamic typing accepted it happily -- so the column silently worked until a
real migration hit it with real data.
"""
def test_column_is_bigint_not_int(self):
from sqlalchemy import BigInteger
from api.monitor.models import MonitorRun
column_type = MonitorRun.__table__.c.exit_code.type
assert isinstance(column_type, BigInteger), (
f"exit_code must be BigInteger to hold unsigned 32-bit codes, got {column_type!r}"
)
@pytest.mark.asyncio
async def test_an_ntstatus_value_round_trips(self, db):
task = await _make_task(db)
run = await _make_run(db, task, started_at=1000, exit_code=3221225794)
await db.commit()
stored = await db.scalar(
select(MonitorRun.exit_code).where(MonitorRun.id == run.id)
)
assert stored == 3221225794
class TestDescribeExitCode:
def test_windows_status_code_is_decoded(self):
"""3221225794 is 0xC0000142, which is meaningless without decoding."""
message = describe_exit_code(3221225794)
assert "0xC0000142" in message
assert "DLL_INIT_FAILED" in message
def test_negative_signed_form_is_also_decoded(self):
# Python may hand back the signed form depending on how it was launched.
assert "0xC0000142" in describe_exit_code(-1073741502)
def test_unknown_code_degrades_to_the_raw_number(self):
assert describe_exit_code(1) == "Crawler exited with code 1"
# --------------------------------------------------------------------------
# Notes
# --------------------------------------------------------------------------
class TestNoteIngest:
@pytest.mark.asyncio
async def test_baseline_run_emits_no_new_note_events(self, db, tmp_path):
task = await _make_task(db)
run = await _make_run(db, task, started_at=1000)
_write_run_dir(tmp_path, [_note("n1"), _note("n2")], comments=[])
result = await ingest_run(db, run, task, tmp_path)
assert result.status == RUN_SUCCESS
assert result.is_baseline is True
assert result.new_notes == 2
# Everything is "new" on the first run; emitting that would be pure noise.
assert await _events(db, EVENT_NEW_NOTE) == []
assert len(list((await db.scalars(select(MonitorNote))).all())) == 2
@pytest.mark.asyncio
async def test_an_empty_run_does_not_establish_a_baseline(self, db, tmp_path):
"""A run that fetched nothing observed nothing, so it is not a baseline.
Otherwise the first crawl that actually works reports every work as
newly discovered.
"""
task = await _make_task(db)
empty_run = await _make_run(db, task, started_at=1000)
(tmp_path / "empty").mkdir(parents=True, exist_ok=True)
await ingest_run(db, empty_run, task, tmp_path / "empty")
real_run = await _make_run(db, task, started_at=2000)
result = await ingest_run(
db, real_run, task, _write_run_dir(tmp_path / "ok", [_note("n1")], comments=[])
)
assert result.is_baseline is True
assert await _events(db, EVENT_NEW_NOTE) == []
@pytest.mark.asyncio
async def test_second_run_reports_only_the_added_note(self, db, tmp_path):
task = await _make_task(db)
first_dir = _write_run_dir(tmp_path / "run1", [_note("n1")], comments=[])
run1 = await _make_run(db, task, started_at=1000)
await ingest_run(db, run1, task, first_dir)
second_dir = _write_run_dir(tmp_path / "run2", [_note("n1"), _note("n2")], comments=[])
run2 = await _make_run(db, task, started_at=2000)
result = await ingest_run(db, run2, task, second_dir)
assert result.is_baseline is False
assert result.new_notes == 1
events = await _events(db, EVENT_NEW_NOTE)
assert len(events) == 1
assert events[0].target_id == "n2"
assert events[0].run_id == run2.id
class TestMetricSnapshots:
@pytest.mark.asyncio
async def test_delta_event_emitted_when_like_count_changes(self, db, tmp_path):
task = await _make_task(db)
run1 = await _make_run(db, task, started_at=1000)
await ingest_run(db, run1, task, _write_run_dir(tmp_path / "r1", [_note("n1", "100")], comments=[]))
run2 = await _make_run(db, task, started_at=2000)
await ingest_run(db, run2, task, _write_run_dir(tmp_path / "r2", [_note("n1", "150")], comments=[]))
events = await _events(db, EVENT_METRIC_DELTA)
assert len(events) == 1
payload = json.loads(events[0].payload_json)
assert payload["deltas"]["liked_count"] == {"from": 100, "to": 150, "delta": 50}
@pytest.mark.asyncio
async def test_no_delta_when_nothing_changed(self, db, tmp_path):
task = await _make_task(db)
run1 = await _make_run(db, task, started_at=1000)
await ingest_run(db, run1, task, _write_run_dir(tmp_path / "r1", [_note("n1", "100")], comments=[]))
run2 = await _make_run(db, task, started_at=2000)
await ingest_run(db, run2, task, _write_run_dir(tmp_path / "r2", [_note("n1", "100")], comments=[]))
assert await _events(db, EVENT_METRIC_DELTA) == []
@pytest.mark.asyncio
async def test_unparseable_count_is_null_not_zero(self, db, tmp_path):
task = await _make_task(db)
run = await _make_run(db, task, started_at=1000)
await ingest_run(db, run, task, _write_run_dir(tmp_path, [_note("n1", "暂无")], comments=[]))
metric = await db.scalar(select(MonitorNoteMetric).where(MonitorNoteMetric.note_id == "n1"))
# Zero would forge a large negative delta on the next comparison.
assert metric.liked_count is None
assert metric.raw_liked_count == "暂无"
@pytest.mark.asyncio
async def test_no_delta_when_previous_value_was_unparseable(self, db, tmp_path):
task = await _make_task(db)
run1 = await _make_run(db, task, started_at=1000)
await ingest_run(db, run1, task, _write_run_dir(tmp_path / "r1", [_note("n1", "暂无")], comments=[]))
run2 = await _make_run(db, task, started_at=2000)
await ingest_run(db, run2, task, _write_run_dir(tmp_path / "r2", [_note("n1", "50")], comments=[]))
assert await _events(db, EVENT_METRIC_DELTA) == []
@pytest.mark.asyncio
async def test_metric_snapshot_survives_across_runs(self, db, tmp_path):
"""The crawler's own DB store overwrites metrics; ours must not."""
task = await _make_task(db)
for index, liked in enumerate(["100", "150", "300"]):
run = await _make_run(db, task, started_at=1000 * (index + 1))
await ingest_run(
db, run, task, _write_run_dir(tmp_path / f"r{index}", [_note("n1", liked)], comments=[])
)
snapshots = list(
(
await db.scalars(
select(MonitorNoteMetric)
.where(MonitorNoteMetric.note_id == "n1")
.order_by(MonitorNoteMetric.run_id)
)
).all()
)
assert [s.liked_count for s in snapshots] == [100, 150, 300]
# --------------------------------------------------------------------------
# Comments
# --------------------------------------------------------------------------
class TestCommentIngest:
@pytest.mark.asyncio
async def test_posted_vs_seen_split_by_create_time(self, db, tmp_path):
task = await _make_task(db)
# Baseline establishes the seen-set; no events on the first run.
run1 = await _make_run(db, task, started_at=1000)
await ingest_run(
db, run1, task,
_write_run_dir(tmp_path / "r1", [_note("n1")], comments=[_comment("c1", "n1", create_time=500)]),
)
assert await _events(db, EVENT_NEW_COMMENT_POSTED) == []
# c2 was published after run1 started -> genuinely new.
# c3 is old but only just surfaced in the top-N window -> seen, not posted.
run2 = await _make_run(db, task, started_at=2000)
await ingest_run(
db, run2, task,
_write_run_dir(
tmp_path / "r2",
[_note("n1")],
comments=[
_comment("c1", "n1", create_time=500),
_comment("c2", "n1", create_time=2500),
_comment("c3", "n1", create_time=100),
],
),
)
posted = await _events(db, EVENT_NEW_COMMENT_POSTED)
seen = await _events(db, EVENT_NEW_COMMENT_SEEN)
assert len(posted) == 1
assert json.loads(posted[0].payload_json)["comment_id"] == "c2"
assert len(seen) == 1
assert json.loads(seen[0].payload_json)["comment_id"] == "c3"
@pytest.mark.asyncio
async def test_comments_not_ingested_when_disabled(self, db, tmp_path):
task = await _make_task(db, enable_comments=False)
run = await _make_run(db, task, started_at=1000)
result = await ingest_run(
db, run, task,
_write_run_dir(tmp_path, [_note("n1")], comments=[_comment("c1", "n1", 500)]),
)
assert result.new_comments == 0
# --------------------------------------------------------------------------
# Failure handling
# --------------------------------------------------------------------------
class TestFailureHandling:
@pytest.mark.asyncio
async def test_nonzero_exit_is_a_failure(self, db, tmp_path):
task = await _make_task(db)
run = await _make_run(db, task, started_at=1000, exit_code=1)
_write_run_dir(tmp_path, [_note("n1")], comments=[])
result = await ingest_run(db, run, task, tmp_path)
assert result.status == RUN_FAILED
assert len(await _events(db, EVENT_RUN_FAILED)) == 1
# A crashed run must not touch the seen-set.
assert await db.scalar(select(MonitorNote.id)) is None
@pytest.mark.asyncio
async def test_zero_notes_with_exit_zero_is_a_suspected_auth_failure(self, db, tmp_path):
"""The silent-cookie-failure signature: exit 0 but nothing fetched.
A real bad-cookie run writes no output file at all, which is why the
exit code has to be checked before the files are.
"""
task = await _make_task(db)
run = await _make_run(db, task, started_at=1000, exit_code=0)
tmp_path.mkdir(parents=True, exist_ok=True)
result = await ingest_run(db, run, task, tmp_path)
assert result.status == RUN_PARTIAL
assert len(await _events(db, EVENT_AUTH_FAILURE)) == 1
assert await _events(db, EVENT_RUN_FAILED) == []
@pytest.mark.asyncio
async def test_no_data_is_not_blamed_on_the_cookie_when_a_sibling_succeeded(
self, db, tmp_path
):
"""A task that just worked proves the login is fine; do not cry wolf."""
healthy = await _make_task(db, name="healthy")
healthy_run = await _make_run(db, healthy, started_at=get_current_timestamp())
await ingest_run(
db, healthy_run, healthy,
_write_run_dir(tmp_path / "ok", [_note("n1")], comments=[]),
)
task = await _make_task(db, name="suspect")
run = await _make_run(db, task, started_at=get_current_timestamp())
(tmp_path / "empty").mkdir(parents=True, exist_ok=True)
result = await ingest_run(db, run, task, tmp_path / "empty")
assert result.status == RUN_PARTIAL
assert await _events(db, EVENT_NO_DATA) != []
assert await _events(db, EVENT_AUTH_FAILURE) == []
@pytest.mark.asyncio
async def test_empty_contents_file_is_also_an_auth_failure(self, db, tmp_path):
task = await _make_task(db)
run = await _make_run(db, task, started_at=1000, exit_code=0)
_write_run_dir(tmp_path, [], comments=[])
result = await ingest_run(db, run, task, tmp_path)
assert result.status == RUN_PARTIAL
assert len(await _events(db, EVENT_AUTH_FAILURE)) == 1
# --------------------------------------------------------------------------
# Idempotency
# --------------------------------------------------------------------------
class TestIdempotency:
@pytest.mark.asyncio
async def test_reingesting_the_same_data_adds_nothing(self, db, tmp_path):
task = await _make_task(db)
run_dir = _write_run_dir(
tmp_path, [_note("n1"), _note("n2")], comments=[_comment("c1", "n1", 500)]
)
run1 = await _make_run(db, task, started_at=1000)
await ingest_run(db, run1, task, run_dir)
notes_after_first = len(list((await db.scalars(select(MonitorNote))).all()))
# A retry of the same crawl content must not duplicate rows or events.
run2 = await _make_run(db, task, started_at=2000)
result = await ingest_run(db, run2, task, run_dir)
assert result.new_notes == 0
assert result.new_comments == 0
assert len(list((await db.scalars(select(MonitorNote))).all())) == notes_after_first
+315
View File
@@ -0,0 +1,315 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_notify.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for the WeCom notification layer.
The webhook is stubbed, so nothing here touches the network.
"""
import json
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from api.monitor import notify
from api.monitor.models import (
EVENT_AUTH_FAILURE,
EVENT_METRIC_DELTA,
EVENT_NEW_NOTE,
EVENT_NEW_COMMENT_POSTED,
MODE_CREATOR,
SETTING_WECOM_WEBHOOK,
MonitorBase,
MonitorEvent,
MonitorRun,
MonitorTask,
RUN_SUCCESS,
)
from api.monitor.settings import set_setting
WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abc123"
@pytest_asyncio.fixture
async def db():
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
async with engine.begin() as conn:
await conn.run_sync(MonitorBase.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
await engine.dispose()
async def _seed(db: AsyncSession, notify_enabled: bool = True):
task = MonitorTask(
name="竞品监控", platform="xhs", mode=MODE_CREATOR, enabled=True,
interval_minutes=60, max_notes_count=20, enable_comments=True,
max_comments_count=50, run_timeout_seconds=3600,
notify_enabled=notify_enabled, created_at=0, updated_at=0,
)
db.add(task)
await db.flush()
run = MonitorRun(
task_id=task.id, trigger="scheduled", status=RUN_SUCCESS, phase=MODE_CREATOR,
save_data_path="", queued_at=0, not_before=0, max_comments_count=50,
)
db.add(run)
await db.flush()
return task, run
def _add_event(db, task, run, event_type, title, payload=None, severity="info"):
db.add(
MonitorEvent(
task_id=task.id, run_id=run.id, type=event_type, severity=severity,
target_kind="note", target_id="note-1", title=title,
payload_json=json.dumps(payload or {}, ensure_ascii=False), created_at=0,
)
)
# --------------------------------------------------------------------------
# Message building
# --------------------------------------------------------------------------
class TestBuildRunMessage:
@pytest.mark.asyncio
async def test_no_notifiable_events_means_no_message(self, db):
task, run = await _seed(db)
# Metric deltas are not something anyone wants pushed.
_add_event(db, task, run, EVENT_METRIC_DELTA, "点赞 10→20")
_add_event(db, task, run, EVENT_NEW_COMMENT_POSTED, "新评论")
await db.flush()
assert await notify.build_run_message(db, task, run) is None
@pytest.mark.asyncio
async def test_new_notes_are_listed_with_links(self, db):
task, run = await _seed(db)
_add_event(
db, task, run, EVENT_NEW_NOTE, "新作品:标题A",
payload={"note_id": "abc123", "title": "标题A"},
)
await db.flush()
message = await notify.build_run_message(db, task, run)
assert "竞品监控" in message
assert "新增作品 **1** 篇" in message
assert "标题A" in message
assert "https://www.xiaohongshu.com/explore/abc123" in message
@pytest.mark.asyncio
async def test_long_note_lists_are_truncated(self, db):
"""A first run can find dozens; a wall of text is worse than a count."""
task, run = await _seed(db)
for index in range(14):
_add_event(
db, task, run, EVENT_NEW_NOTE, f"新作品:{index}",
payload={"note_id": f"n{index}", "title": f"标题{index}"},
)
await db.flush()
message = await notify.build_run_message(db, task, run)
assert "新增作品 **14** 篇" in message
assert "标题0" in message
assert "标题13" not in message
assert "等共 14 篇" in message
@pytest.mark.asyncio
async def test_failure_is_reported_as_a_warning(self, db):
task, run = await _seed(db)
_add_event(
db, task, run, EVENT_AUTH_FAILURE,
"疑似登录态失效:本次未抓到任何作品", severity="error",
)
await db.flush()
message = await notify.build_run_message(db, task, run)
assert "异常" in message
assert "登录态失效" in message
assert notify._COLOR_WARNING in message
@pytest.mark.asyncio
async def test_baseline_runs_say_so(self, db):
task, run = await _seed(db)
run.is_baseline = True
_add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"})
await db.flush()
message = await notify.build_run_message(db, task, run)
assert "基线" in message
# --------------------------------------------------------------------------
# notify_run gating
# --------------------------------------------------------------------------
class TestNotifyRunGating:
@pytest.mark.asyncio
async def test_disabled_task_is_skipped(self, db, monkeypatch):
task, run = await _seed(db, notify_enabled=False)
_add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"})
await set_setting(db, SETTING_WECOM_WEBHOOK, WEBHOOK)
await db.flush()
called = []
monkeypatch.setattr(notify, "send_wecom", lambda *a, **k: called.append(a) or _ok())
assert await notify.notify_run(db, task, run) is None
assert called == []
@pytest.mark.asyncio
async def test_missing_webhook_is_skipped(self, db, monkeypatch):
task, run = await _seed(db, notify_enabled=True)
_add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"})
await db.flush()
called = []
monkeypatch.setattr(notify, "send_wecom", lambda *a, **k: called.append(a) or _ok())
assert await notify.notify_run(db, task, run) is None
assert called == []
@pytest.mark.asyncio
async def test_successful_push_records_the_timestamp(self, db, monkeypatch):
task, run = await _seed(db, notify_enabled=True)
_add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"})
await set_setting(db, SETTING_WECOM_WEBHOOK, WEBHOOK)
await db.flush()
monkeypatch.setattr(notify, "send_wecom", lambda *a, **k: _ok())
message = await notify.notify_run(db, task, run)
assert message is not None
# Lets the UI answer "why did I not get a push for this run?".
assert task.last_notified_at is not None
@pytest.mark.asyncio
async def test_push_failure_never_raises(self, db, monkeypatch):
"""A broken webhook must not take down the crawl that just succeeded."""
task, run = await _seed(db, notify_enabled=True)
_add_event(db, task, run, EVENT_NEW_NOTE, "新作品", payload={"note_id": "x", "title": "t"})
await set_setting(db, SETTING_WECOM_WEBHOOK, WEBHOOK)
await db.flush()
async def _boom(*args, **kwargs):
raise RuntimeError("network exploded")
monkeypatch.setattr(notify, "send_wecom", _boom)
assert await notify.notify_run(db, task, run) is None
async def _ok():
return True, "发送成功"
# --------------------------------------------------------------------------
# send_wecom
# --------------------------------------------------------------------------
class _FakeResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
class _FakeClient:
"""Captures the request and replays a canned WeCom reply."""
last_payload = None
def __init__(self, reply=None, error=None):
self._reply = reply if reply is not None else {"errcode": 0, "errmsg": "ok"}
self._error = error
def __call__(self, *args, **kwargs):
return self
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def post(self, url, json=None):
if self._error:
raise self._error
type(self).last_payload = json
return _FakeResponse(self._reply)
class TestSendWecom:
@pytest.mark.asyncio
async def test_missing_url_is_reported(self):
ok, detail = await notify.send_wecom("", "hi")
assert ok is False
assert "未配置" in detail
@pytest.mark.asyncio
async def test_success(self, monkeypatch):
monkeypatch.setattr(notify.httpx, "AsyncClient", _FakeClient())
ok, detail = await notify.send_wecom(WEBHOOK, "**标题**\n> 内容")
assert ok is True
assert detail == "发送成功"
# WeCom expects a markdown message envelope.
assert _FakeClient.last_payload["msgtype"] == "markdown"
assert _FakeClient.last_payload["markdown"]["content"] == "**标题**\n> 内容"
@pytest.mark.asyncio
async def test_nonzero_errcode_is_a_failure(self, monkeypatch):
"""WeCom answers HTTP 200 even when it rejects the message."""
monkeypatch.setattr(
notify.httpx, "AsyncClient",
_FakeClient(reply={"errcode": 93000, "errmsg": "invalid webhook url"}),
)
ok, detail = await notify.send_wecom(WEBHOOK, "hi")
assert ok is False
assert "93000" in detail
@pytest.mark.asyncio
async def test_network_error_is_returned_not_raised(self, monkeypatch):
import httpx
monkeypatch.setattr(
notify.httpx, "AsyncClient",
_FakeClient(error=httpx.ConnectError("boom")),
)
ok, detail = await notify.send_wecom(WEBHOOK, "hi")
assert ok is False
assert "请求失败" in detail
+304
View File
@@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_report.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for the cross-task report aggregation.
The interaction delta is the part that is easy to get subtly wrong, so it is
covered directly against the pure aggregation function.
"""
from datetime import date, datetime
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from api.monitor.models import (
MODE_CREATOR,
MonitorBase,
MonitorComment,
MonitorNote,
MonitorNoteMetric,
MonitorTask,
)
from api.monitor.report import build_report, compute_daily_rows, day_bounds, iter_days
def _ms(year: int, month: int, day: int, hour: int = 12) -> int:
return int(datetime(year, month, day, hour).timestamp() * 1000)
def _metrics(liked=0, comment=0, collected=0, share=0):
"""All four metrics default to parsed values; pass None to simulate a
platform value we could not parse."""
return {
"liked_count": liked,
"comment_count": comment,
"collected_count": collected,
"share_count": share,
}
class TestDayHelpers:
def test_day_bounds_cover_the_whole_local_day(self):
start, end = day_bounds(date(2026, 1, 10))
assert start < _ms(2026, 1, 10, 0) or start == _ms(2026, 1, 10, 0)
assert end > _ms(2026, 1, 10, 23)
def test_iter_days_is_inclusive(self):
days = iter_days(date(2026, 1, 10), date(2026, 1, 12))
assert days == [date(2026, 1, 10), date(2026, 1, 11), date(2026, 1, 12)]
class TestInteractionDelta:
def test_note_first_seen_counts_all_of_its_value(self):
"""A brand-new note has no earlier baseline, so it starts from zero."""
day = date(2026, 1, 10)
series = {"n1": [(_ms(2026, 1, 10, 10), _metrics(liked=100, comment=5))]}
rows = compute_daily_rows(series, {}, {}, [day])
assert rows[0]["liked_count_delta"] == 100
assert rows[0]["comment_count_delta"] == 5
def test_growth_is_split_across_days(self):
series = {
"n1": [
(_ms(2026, 1, 10, 10), _metrics(liked=100)),
(_ms(2026, 1, 11, 10), _metrics(liked=300)),
]
}
rows = compute_daily_rows(series, {}, {}, [date(2026, 1, 10), date(2026, 1, 11)])
# Day 1: 0 -> 100. Day 2: 100 -> 300.
assert [row["liked_count_delta"] for row in rows] == [100, 200]
def test_day_without_a_snapshot_reports_no_growth(self):
series = {
"n1": [
(_ms(2026, 1, 10, 10), _metrics(liked=100)),
(_ms(2026, 1, 12, 10), _metrics(liked=400)),
]
}
days = [date(2026, 1, 10), date(2026, 1, 11), date(2026, 1, 12)]
rows = compute_daily_rows(series, {}, {}, days)
# The note was not crawled on the 11th, so nothing is claimed for it.
assert [row["liked_count_delta"] for row in rows] == [100, 0, 300]
def test_deltas_aggregate_across_notes(self):
series = {
"n1": [
(_ms(2026, 1, 10, 10), _metrics(liked=100)),
(_ms(2026, 1, 11, 10), _metrics(liked=150)),
],
"n2": [
(_ms(2026, 1, 10, 10), _metrics(liked=10)),
(_ms(2026, 1, 11, 10), _metrics(liked=40)),
],
}
rows = compute_daily_rows(series, {}, {}, [date(2026, 1, 10), date(2026, 1, 11)])
assert [row["liked_count_delta"] for row in rows] == [110, 80]
def test_unparseable_metric_names_the_offending_field(self):
"""A NULL count makes the delta unknown; it must not be reported as 0."""
series = {
"n1": [
(_ms(2026, 1, 10, 10), _metrics(liked=100, comment=None)),
(_ms(2026, 1, 11, 10), _metrics(liked=200, comment=None)),
]
}
rows = compute_daily_rows(series, {}, {}, [date(2026, 1, 11)])
# Naming the field is actionable; a bare boolean is not.
assert rows[0]["partial_metrics"] == ["comment_count"]
# The parseable metric is still summed correctly.
assert rows[0]["liked_count_delta"] == 100
def test_unknown_value_only_taints_the_days_it_touches(self):
series = {
"n1": [
(_ms(2026, 1, 10, 10), _metrics(liked=None)),
(_ms(2026, 1, 11, 10), _metrics(liked=50)),
(_ms(2026, 1, 12, 10), _metrics(liked=90)),
]
}
days = [date(2026, 1, 10), date(2026, 1, 11), date(2026, 1, 12)]
rows = compute_daily_rows(series, {}, {}, days)
# Day 12 compares two known values, so it is clean.
assert [row["partial_metrics"] for row in rows] == [
["liked_count"],
["liked_count"],
[],
]
assert rows[2]["liked_count_delta"] == 40
def test_new_content_counts_come_from_the_day_maps(self):
rows = compute_daily_rows(
{},
{date(2026, 1, 10): 3},
{date(2026, 1, 10): 7},
[date(2026, 1, 10), date(2026, 1, 11)],
)
assert rows[0]["new_notes"] == 3
assert rows[0]["new_comments"] == 7
assert rows[1]["new_notes"] == 0
# --------------------------------------------------------------------------
# DB-backed report + task filtering
# --------------------------------------------------------------------------
@pytest_asyncio.fixture
async def db():
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
async with engine.begin() as conn:
await conn.run_sync(MonitorBase.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
await engine.dispose()
async def _seed_task(db: AsyncSession, name: str) -> MonitorTask:
task = MonitorTask(
name=name, platform="xhs", mode=MODE_CREATOR, enabled=True,
interval_minutes=60, max_notes_count=20, enable_comments=True,
max_comments_count=50, run_timeout_seconds=3600,
notify_enabled=False, created_at=0, updated_at=0,
)
db.add(task)
await db.flush()
return task
async def _seed_note_with_metrics(
db: AsyncSession, task: MonitorTask, note_id: str, samples
) -> None:
db.add(
MonitorNote(
task_id=task.id, note_id=note_id, title=note_id, note_url="",
cover="", creator_hash="", source_kind="", published_at=None,
first_seen_run_id=1, first_seen_at=samples[0][0],
last_seen_run_id=len(samples), last_seen_at=samples[-1][0],
)
)
for run_id, (ts, liked) in enumerate(samples, start=1):
db.add(
MonitorNoteMetric(
task_id=task.id, note_id=note_id, run_id=run_id, captured_at=ts,
liked_count=liked, comment_count=0, collected_count=0, share_count=0,
raw_liked_count=str(liked), raw_comment_count="0",
raw_collected_count="0", raw_share_count="0",
)
)
class TestBuildReport:
@pytest.mark.asyncio
async def test_totals_and_rows(self, db):
task = await _seed_task(db, "t1")
await _seed_note_with_metrics(
db, task, "n1",
[(_ms(2026, 1, 10, 10), 100), (_ms(2026, 1, 11, 10), 250)],
)
await db.commit()
result = await build_report(db, [task.id], date(2026, 1, 10), date(2026, 1, 11))
assert result["totals"]["liked_count_delta"] == 250
assert len(result["rows"]) == 2
assert result["note_count"] == 1
@pytest.mark.asyncio
async def test_task_selection_isolates_the_report(self, db):
"""The whole point: a report for a chosen subset must exclude the rest."""
kept = await _seed_task(db, "kept")
other = await _seed_task(db, "other")
await _seed_note_with_metrics(db, kept, "n1", [(_ms(2026, 1, 10, 10), 100)])
await _seed_note_with_metrics(db, other, "n2", [(_ms(2026, 1, 10, 10), 999)])
await db.commit()
only_kept = await build_report(db, [kept.id], date(2026, 1, 10), date(2026, 1, 10))
assert only_kept["totals"]["liked_count_delta"] == 100
assert only_kept["note_count"] == 1
both = await build_report(db, [kept.id, other.id], date(2026, 1, 10), date(2026, 1, 10))
assert both["totals"]["liked_count_delta"] == 1099
@pytest.mark.asyncio
async def test_no_task_filter_covers_everything(self, db):
first = await _seed_task(db, "a")
second = await _seed_task(db, "b")
await _seed_note_with_metrics(db, first, "n1", [(_ms(2026, 1, 10, 10), 10)])
await _seed_note_with_metrics(db, second, "n2", [(_ms(2026, 1, 10, 10), 20)])
await db.commit()
result = await build_report(db, None, date(2026, 1, 10), date(2026, 1, 10))
assert result["totals"]["liked_count_delta"] == 30
assert result["task_ids"] is None
@pytest.mark.asyncio
async def test_baseline_from_before_the_range_is_used(self, db):
"""Growth is measured against the last value before the window opens."""
task = await _seed_task(db, "t")
await _seed_note_with_metrics(
db, task, "n1",
[(_ms(2026, 1, 5, 10), 1000), (_ms(2026, 1, 10, 10), 1050)],
)
await db.commit()
# Report only for the 10th: the delta must be 50, not 1050.
result = await build_report(db, [task.id], date(2026, 1, 10), date(2026, 1, 10))
assert result["totals"]["liked_count_delta"] == 50
@pytest.mark.asyncio
async def test_empty_range_returns_zeroed_rows(self, db):
result = await build_report(db, None, date(2026, 2, 1), date(2026, 2, 3))
assert len(result["rows"]) == 3
assert result["totals"]["liked_count_delta"] == 0
assert result["totals"]["new_notes"] == 0
@pytest.mark.asyncio
async def test_new_comments_are_counted_by_first_seen_day(self, db):
task = await _seed_task(db, "t")
db.add(
MonitorComment(
task_id=task.id, note_id="n1", comment_id="c1", content="x",
nickname="u", creator_hash="h", create_time=_ms(2026, 1, 9),
like_count=0, sub_comment_count=0, parent_comment_id="",
first_seen_run_id=1, first_seen_at=_ms(2026, 1, 10, 10),
)
)
await db.commit()
result = await build_report(db, [task.id], date(2026, 1, 10), date(2026, 1, 10))
assert result["totals"]["new_comments"] == 1
+266
View File
@@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_monitor_scheduler.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Tests for the monitor scheduler's firing, deferral and recovery rules."""
import pytest
import pytest_asyncio
from sqlalchemy import select
from api.monitor import db as monitor_db
from api.monitor import scheduler as scheduler_module
from api.monitor.models import (
MODE_CREATOR,
MonitorRun,
MonitorTarget,
MonitorTask,
RUN_INTERRUPTED,
RUN_RUNNING,
RUN_SUCCESS,
)
from api.monitor.scheduler import MonitorScheduler
from api.monitor.settings import set_cookie
from tools.time_util import get_current_timestamp
MS_PER_MINUTE = 60_000
class FakeCrawlerManager:
"""Stands in for the global subprocess singleton."""
def __init__(self, busy: bool = False) -> None:
self.busy = busy
def is_busy(self) -> bool:
return self.busy
@pytest_asyncio.fixture
async def db(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
async with monitor_db.get_session() as session:
await set_cookie(session, "web_session=test")
yield monitor_db
await monitor_db.dispose_engine()
@pytest_asyncio.fixture
async def executed(monkeypatch):
"""Record execute_task calls instead of launching a real crawl."""
calls: list[tuple[int, str]] = []
async def _fake_execute(task_id: int, trigger: str = "manual"):
calls.append((task_id, trigger))
monkeypatch.setattr(scheduler_module, "execute_task", _fake_execute)
return calls
async def _make_task(next_run_at, enabled: bool = True, interval: int = 60) -> int:
async with monitor_db.get_session() as session:
now = get_current_timestamp()
task = MonitorTask(
name="t",
platform="xhs",
mode=MODE_CREATOR,
enabled=enabled,
interval_minutes=interval,
max_notes_count=20,
enable_comments=True,
max_comments_count=50,
run_timeout_seconds=3600,
next_run_at=next_run_at,
last_status="idle",
created_at=now,
updated_at=now,
)
session.add(task)
await session.flush()
session.add(
MonitorTarget(
task_id=task.id,
kind=MODE_CREATOR,
external_id="abc123",
xsec_token="",
xsec_source="",
raw_value="abc123",
label="abc123",
enabled=True,
created_at=now,
)
)
return task.id
async def _get_task(task_id: int) -> MonitorTask:
async with monitor_db.get_session() as session:
return await session.get(MonitorTask, task_id)
class TestFiring:
@pytest.mark.asyncio
async def test_due_task_runs_and_advances(self, monkeypatch, db, executed):
monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False))
past = get_current_timestamp() - MS_PER_MINUTE
task_id = await _make_task(past)
await MonitorScheduler().tick()
assert executed == [(task_id, "scheduled")]
task = await _get_task(task_id)
# Fixed-delay: the next fire is measured from now, not from the missed slot.
assert task.next_run_at > get_current_timestamp()
@pytest.mark.asyncio
async def test_future_task_does_not_run(self, monkeypatch, db, executed):
monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False))
await _make_task(get_current_timestamp() + 10 * MS_PER_MINUTE)
await MonitorScheduler().tick()
assert executed == []
@pytest.mark.asyncio
async def test_disabled_task_does_not_run(self, monkeypatch, db, executed):
monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False))
await _make_task(get_current_timestamp() - MS_PER_MINUTE, enabled=False)
await MonitorScheduler().tick()
assert executed == []
@pytest.mark.asyncio
async def test_long_outage_coalesces_into_one_run(self, monkeypatch, db, executed):
"""A missed schedule fires once, not once per missed interval."""
monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False))
# Due two days ago on a 1-hour interval.
await _make_task(get_current_timestamp() - 48 * 60 * MS_PER_MINUTE)
scheduler = MonitorScheduler()
await scheduler.tick()
await scheduler.tick()
assert len(executed) == 1
class TestDeferral:
@pytest.mark.asyncio
async def test_busy_crawler_defers_without_advancing(self, monkeypatch, db, executed):
"""A manual crawl must not consume the monitor task's slot or lose it."""
monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=True))
due_at = get_current_timestamp() - MS_PER_MINUTE
task_id = await _make_task(due_at)
await MonitorScheduler().tick()
assert executed == []
task = await _get_task(task_id)
# Still due, so the next free tick picks it up rather than skipping a cycle.
assert task.next_run_at == due_at
@pytest.mark.asyncio
async def test_deferred_task_runs_once_crawler_frees_up(self, monkeypatch, db, executed):
fake = FakeCrawlerManager(busy=True)
monkeypatch.setattr(scheduler_module, "crawler_manager", fake)
task_id = await _make_task(get_current_timestamp() - MS_PER_MINUTE)
scheduler = MonitorScheduler()
await scheduler.tick()
assert executed == []
fake.busy = False
await scheduler.tick()
assert executed == [(task_id, "scheduled")]
class TestCookieGuard:
@pytest.mark.asyncio
async def test_no_cookie_blocks_run_and_keeps_task_due(self, monkeypatch, db, executed):
"""Without a cookie every run would be an auth failure; skip instead."""
monkeypatch.setattr(scheduler_module, "crawler_manager", FakeCrawlerManager(busy=False))
async with monitor_db.get_session() as session:
from api.monitor.settings import cookie_key, delete_setting
await delete_setting(session, cookie_key("xhs"))
due_at = get_current_timestamp() - MS_PER_MINUTE
task_id = await _make_task(due_at)
await MonitorScheduler().tick()
assert executed == []
task = await _get_task(task_id)
# Left due so it starts working the moment a cookie is pasted.
assert task.next_run_at == due_at
class TestRecovery:
@pytest.mark.asyncio
async def test_running_runs_are_marked_interrupted(self, db):
"""A run left 'running' cannot be alive -- its process died with the server."""
async with monitor_db.get_session() as session:
now = get_current_timestamp()
task = MonitorTask(
name="t", platform="xhs", mode=MODE_CREATOR, enabled=True,
interval_minutes=60, max_notes_count=20, enable_comments=True,
max_comments_count=50, run_timeout_seconds=3600,
next_run_at=now, last_status="running", created_at=now, updated_at=now,
)
session.add(task)
await session.flush()
session.add(
MonitorRun(
task_id=task.id, trigger="scheduled", status=RUN_RUNNING,
phase=MODE_CREATOR, save_data_path="", queued_at=now, not_before=0,
started_at=now, max_comments_count=50,
)
)
await MonitorScheduler().recover()
async with monitor_db.get_session() as session:
run = await session.scalar(select(MonitorRun))
assert run.status == RUN_INTERRUPTED
assert run.finished_at is not None
@pytest.mark.asyncio
async def test_completed_runs_are_left_alone(self, db):
async with monitor_db.get_session() as session:
now = get_current_timestamp()
task = MonitorTask(
name="t", platform="xhs", mode=MODE_CREATOR, enabled=True,
interval_minutes=60, max_notes_count=20, enable_comments=True,
max_comments_count=50, run_timeout_seconds=3600,
next_run_at=now, last_status="success", created_at=now, updated_at=now,
)
session.add(task)
await session.flush()
session.add(
MonitorRun(
task_id=task.id, trigger="scheduled", status=RUN_SUCCESS,
phase=MODE_CREATOR, save_data_path="", queued_at=now, not_before=0,
started_at=now, finished_at=now, max_comments_count=50,
)
)
await MonitorScheduler().recover()
async with monitor_db.get_session() as session:
run = await session.scalar(select(MonitorRun))
assert run.status == RUN_SUCCESS
+302
View File
@@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_platforms.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Platform capability matrix, platform scoping, and per-platform settings."""
import httpx
import pytest
import pytest_asyncio
from sqlalchemy import text
from api.main import app
from api.monitor import db as monitor_db
from api.monitor import platforms
from api.monitor.models import MonitorTask
XHS_TARGET = "5f58bd990000000001003753"
@pytest_asyncio.fixture
async def client(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
await monitor_db.dispose_engine()
class TestCapabilityMatrix:
@pytest.mark.asyncio
async def test_matrix_is_exposed_to_the_ui(self, client):
body = (await client.get("/api/config/platforms")).json()
by_value = {p["value"]: p for p in body["platforms"]}
assert set(by_value) == {"xhs", "dy", "ks", "bili", "wb", "tieba", "zhihu"}
# Every entry must say whether monitoring is actually wired up -- this is
# what stops the UI offering a platform that can never produce data.
assert all("monitor_wired" in p for p in body["platforms"])
assert by_value["xhs"]["monitor_wired"] is True
assert by_value["dy"]["monitor_wired"] is False
@pytest.mark.asyncio
async def test_metrics_are_per_platform_and_labelled(self, client):
body = (await client.get("/api/config/platforms")).json()
by_value = {p["value"]: p for p in body["platforms"]}
# Bilibili has play count and danmaku; Xiaohongshu has neither.
assert "video_play_count" in by_value["bili"]["metrics"]
assert "video_danmaku" in by_value["bili"]["metrics"]
assert "video_play_count" not in by_value["xhs"]["metrics"]
# Every metric shown to a user must have a human label.
for capability in body["platforms"]:
for metric in capability["metrics"]:
assert capability["metric_labels"][metric]
def test_unknown_platform_is_not_monitor_wired(self):
assert platforms.is_known("xhs") is True
assert platforms.is_known("myspace") is False
assert platforms.is_monitor_wired("myspace") is False
class TestTaskCreationGuard:
@pytest.mark.asyncio
async def test_unwired_platform_is_rejected_with_an_explanation(self, client):
"""Accepting it would create a task that silently never produces data."""
response = await client.post(
"/api/monitor/tasks",
json={"name": "抖音任务", "mode": "creator", "platform": "dy", "targets": ["x"]},
)
assert response.status_code == 400
detail = response.json()["detail"]
assert "抖音" in detail
assert "尚未接通" in detail
@pytest.mark.asyncio
async def test_unknown_platform_is_rejected(self, client):
response = await client.post(
"/api/monitor/tasks",
json={"name": "x", "mode": "creator", "platform": "myspace", "targets": ["x"]},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_no_task_row_is_created_when_rejected(self, client):
await client.post(
"/api/monitor/tasks",
json={"name": "抖音任务", "mode": "creator", "platform": "dy", "targets": ["x"]},
)
assert (await client.get("/api/monitor/tasks")).json()["tasks"] == []
@pytest.mark.asyncio
async def test_xhs_still_works_and_is_the_default(self, client):
explicit = await client.post(
"/api/monitor/tasks",
json={"name": "显式", "mode": "creator", "platform": "xhs", "targets": [XHS_TARGET]},
)
assert explicit.status_code == 201
defaulted = await client.post(
"/api/monitor/tasks",
json={"name": "默认", "mode": "creator", "targets": [XHS_TARGET]},
)
assert defaulted.status_code == 201
tasks = (await client.get("/api/monitor/tasks")).json()["tasks"]
assert {t["platform"] for t in tasks} == {"xhs"}
class TestPlatformScoping:
async def _seed_two_platforms(self, client):
"""One real XHS task plus a Douyin task inserted directly, since the API
refuses to create the latter."""
await client.post(
"/api/monitor/tasks",
json={"name": "小红书任务", "mode": "creator", "targets": [XHS_TARGET]},
)
async with monitor_db.get_session() as session:
session.add(
MonitorTask(
name="抖音任务", platform="dy", mode="creator", enabled=True,
interval_minutes=60, max_notes_count=20, enable_comments=True,
max_comments_count=50, run_timeout_seconds=3600,
notify_enabled=False, created_at=0, updated_at=0,
)
)
@pytest.mark.asyncio
async def test_tasks_are_filtered_by_platform(self, client):
await self._seed_two_platforms(client)
all_tasks = (await client.get("/api/monitor/tasks")).json()["tasks"]
assert len(all_tasks) == 2
xhs_only = (await client.get("/api/monitor/tasks", params={"platform": "xhs"})).json()
assert [t["name"] for t in xhs_only["tasks"]] == ["小红书任务"]
dy_only = (await client.get("/api/monitor/tasks", params={"platform": "dy"})).json()
assert [t["name"] for t in dy_only["tasks"]] == ["抖音任务"]
@pytest.mark.asyncio
async def test_overview_is_scoped(self, client):
await self._seed_two_platforms(client)
assert (await client.get("/api/monitor/overview")).json()["tasks"] == 2
assert (
await client.get("/api/monitor/overview", params={"platform": "xhs"})
).json()["tasks"] == 1
@pytest.mark.asyncio
async def test_a_platform_with_no_tasks_yields_empty_not_everything(self, client):
"""An empty task set must not degrade into "no filter"."""
await self._seed_two_platforms(client)
body = (await client.get("/api/monitor/notes", params={"platform": "bili"})).json()
assert body["notes"] == []
report = (
await client.get("/api/monitor/report", params={"platform": "bili"})
).json()
assert report["totals"]["liked_count_delta"] == 0
assert report["note_count"] == 0
class TestPerPlatformSettings:
@pytest.mark.asyncio
async def test_each_platform_keeps_its_own_values(self, client):
await client.put(
"/api/settings",
params={"platform": "xhs"},
json={"platform.xhs.crawl_sleep_sec": 3},
)
await client.put(
"/api/settings",
params={"platform": "dy"},
json={"platform.dy.crawl_sleep_sec": 9},
)
xhs = (await client.get("/api/settings", params={"platform": "xhs"})).json()
dy = (await client.get("/api/settings", params={"platform": "dy"})).json()
assert xhs["values"]["platform.xhs.crawl_sleep_sec"] == 3
assert dy["values"]["platform.dy.crawl_sleep_sec"] == 9
@pytest.mark.asyncio
async def test_system_settings_are_shared_across_platforms(self, client):
await client.put(
"/api/settings",
params={"platform": "xhs"},
json={"system.active_hours_start": 8},
)
dy = (await client.get("/api/settings", params={"platform": "dy"})).json()
assert dy["values"]["system.active_hours_start"] == 8
# ...and the system specs are present in every platform's response.
assert "system.active_hours_end" in dy["values"]
@pytest.mark.asyncio
async def test_a_key_for_another_platform_is_rejected(self, client):
"""Writing xhs's key while scoped to dy would land somewhere unexpected."""
response = await client.put(
"/api/settings",
params={"platform": "dy"},
json={"platform.xhs.crawl_sleep_sec": 5},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_cookies_are_per_platform(self, client):
await client.post(
"/api/monitor/cookie",
params={"platform": "xhs"},
json={"cookie": "web_session=xhs-secret"},
)
xhs = (await client.get("/api/monitor/cookie", params={"platform": "xhs"})).json()
dy = (await client.get("/api/monitor/cookie", params={"platform": "dy"})).json()
assert xhs["present"] is True
assert dy["present"] is False
# The old endpoint still defaults to Xiaohongshu.
assert (await client.get("/api/monitor/cookie")).json()["present"] is True
class TestLegacyKeyMigration:
@pytest.mark.asyncio
async def test_old_flat_keys_are_moved_to_the_new_namespace(self, tmp_path):
"""Existing installs must not lose their cookie on upgrade."""
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
async with monitor_db.get_engine().begin() as conn:
await conn.execute(
text(
"INSERT INTO monitor_setting (key, value, updated_at) "
"VALUES ('xhs_cookie', 'web_session=legacy', 1)"
)
)
await conn.execute(
text(
"INSERT INTO monitor_setting (key, value, updated_at) "
"VALUES ('wecom_webhook', 'https://qyapi.weixin.qq.com/x', 1)"
)
)
# Re-running init performs the rename.
await monitor_db.init_db()
async with monitor_db.get_engine().begin() as conn:
rows = dict(
(await conn.execute(text("SELECT key, value FROM monitor_setting"))).all()
)
assert rows.get("platform.xhs.cookie") == "web_session=legacy"
assert rows.get("system.wecom_webhook") == "https://qyapi.weixin.qq.com/x"
assert "xhs_cookie" not in rows
assert "wecom_webhook" not in rows
await monitor_db.dispose_engine()
@pytest.mark.asyncio
async def test_migration_is_idempotent_and_keeps_the_newer_value(self, tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
async with monitor_db.get_engine().begin() as conn:
await conn.execute(
text(
"INSERT INTO monitor_setting (key, value, updated_at) VALUES "
"('platform.xhs.cookie', 'current', 2), ('xhs_cookie', 'stale', 1)"
)
)
await monitor_db.init_db()
async with monitor_db.get_engine().begin() as conn:
rows = dict(
(await conn.execute(text("SELECT key, value FROM monitor_setting"))).all()
)
assert rows.get("platform.xhs.cookie") == "current"
assert "xhs_cookie" not in rows
await monitor_db.dispose_engine()
+374
View File
@@ -0,0 +1,374 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/tests/test_settings.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Unified settings endpoint, and the effect its values actually have."""
from datetime import datetime
import httpx
import pytest
import pytest_asyncio
from api.main import app
from api.monitor import app_settings, db as monitor_db
from api.monitor import scheduler as scheduler_module
from api.monitor.scheduler import MonitorScheduler
from api.monitor.settings import get_setting
SECRET_VALUE = "web_session=SUPERSECRET; a1=abc"
@pytest_asyncio.fixture
async def client(tmp_path):
monitor_db.set_sqlite_path(tmp_path / "monitor.db")
await monitor_db.init_db()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
await monitor_db.dispose_engine()
class TestReadSettings:
@pytest.mark.asyncio
async def test_returns_values_secrets_and_the_spec(self, client):
body = (await client.get("/api/settings")).json()
assert "values" in body and "secrets" in body and "specs" in body
# The spec drives the UI form, so every key must be described.
spec_keys = {spec["key"] for spec in body["specs"]}
assert "platform.xhs.default_interval_minutes" in spec_keys
assert "platform.xhs.enable_ip_proxy" in spec_keys
@pytest.mark.asyncio
async def test_unset_values_fall_back_to_spec_defaults(self, client):
values = (await client.get("/api/settings")).json()["values"]
assert values["platform.xhs.default_interval_minutes"] == 360
assert values["platform.xhs.enable_ip_proxy"] is False
@pytest.mark.asyncio
async def test_secrets_are_masked_never_returned(self, client):
await client.put("/api/settings", json={"platform.xhs.cookie": SECRET_VALUE})
response = await client.get("/api/settings")
assert SECRET_VALUE not in response.text
secret = response.json()["secrets"]["platform.xhs.cookie"]
assert secret["present"] is True
assert secret["length"] == len(SECRET_VALUE)
class TestUpdateSettings:
@pytest.mark.asyncio
async def test_partial_update_leaves_other_keys_alone(self, client):
await client.put(
"/api/settings",
json={"platform.xhs.default_interval_minutes": 120, "platform.xhs.cookie": SECRET_VALUE},
)
# A form that only submits the interval must not blank the cookie.
await client.put("/api/settings", json={"platform.xhs.default_interval_minutes": 240})
body = (await client.get("/api/settings")).json()
assert body["values"]["platform.xhs.default_interval_minutes"] == 240
assert body["secrets"]["platform.xhs.cookie"]["present"] is True
@pytest.mark.asyncio
async def test_empty_string_clears_a_secret(self, client):
await client.put("/api/settings", json={"platform.xhs.cookie": SECRET_VALUE})
await client.put("/api/settings", json={"platform.xhs.cookie": ""})
assert (await client.get("/api/settings")).json()["secrets"]["platform.xhs.cookie"][
"present"
] is False
@pytest.mark.asyncio
async def test_unknown_key_is_rejected(self, client):
response = await client.put("/api/settings", json={"nope.not.a.setting": 1})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_out_of_range_is_rejected(self, client):
response = await client.put(
"/api/settings", json={"platform.xhs.default_interval_minutes": 1}
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_invalid_choice_is_rejected(self, client):
response = await client.put("/api/settings", json={"platform.xhs.proxy_provider": "nonsense"})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_bools_accept_the_ui_shapes(self, client):
for raw in (True, "true", "1", "yes"):
response = await client.put("/api/settings", json={"platform.xhs.enable_ip_proxy": raw})
assert response.status_code == 200
assert (await client.get("/api/settings")).json()["values"][
"platform.xhs.enable_ip_proxy"
] is True
@pytest.mark.asyncio
async def test_password_hash_cannot_be_written_through_this_endpoint(self, client):
"""It has its own authenticated endpoint; this must not be a back door."""
await client.put("/api/settings", json={"auth_password_hash": "pbkdf2_sha256$1$a$b"})
async with monitor_db.get_session() as session:
assert await get_setting(session, "auth_password_hash") is None
class TestSettingsActuallyTakeEffect:
@pytest.mark.asyncio
async def test_new_tasks_use_the_configured_defaults(self, client):
await client.put(
"/api/settings",
json={
"platform.xhs.default_interval_minutes": 120,
"platform.xhs.default_max_notes": 7,
"platform.xhs.default_max_comments": 33,
},
)
await client.post(
"/api/monitor/tasks",
json={"name": "用默认值", "mode": "creator", "targets": ["5f58bd990000000001003753"]},
)
task = (await client.get("/api/monitor/tasks")).json()["tasks"][0]
assert task["interval_minutes"] == 120
assert task["max_notes_count"] == 7
assert task["max_comments_count"] == 33
@pytest.mark.asyncio
async def test_explicit_values_still_win_over_defaults(self, client):
await client.put("/api/settings", json={"platform.xhs.default_interval_minutes": 120})
await client.post(
"/api/monitor/tasks",
json={
"name": "显式值",
"mode": "creator",
"interval_minutes": 720,
"targets": ["5f58bd990000000001003753"],
},
)
task = (await client.get("/api/monitor/tasks")).json()["tasks"][0]
assert task["interval_minutes"] == 720
class TestRunnerAppliesStrategy:
@pytest.mark.asyncio
async def test_strategy_settings_reach_the_command(self, client):
"""Stored settings must actually change how the crawler is invoked."""
from api.services.crawler_manager import CrawlerManager
from api.schemas import CrawlerStartRequest, PlatformEnum, CrawlerTypeEnum
await client.put(
"/api/settings",
json={
"platform.xhs.crawl_sleep_sec": 7,
"platform.xhs.enable_sub_comments": True,
"platform.xhs.enable_ip_proxy": True,
"platform.xhs.proxy_provider": "static",
"platform.xhs.proxy_pool_count": 5,
"platform.xhs.static_proxy_url": "http://127.0.0.1:8888",
},
)
async with monitor_db.get_session() as session:
strategy = await scheduler_module.app_settings.get_value(
session, "crawl_sleep_sec", "xhs", 2
)
assert strategy == 7
# And the flag builder forwards them when present.
command = CrawlerManager()._build_command(
CrawlerStartRequest(
platform=PlatformEnum.XHS,
crawler_type=CrawlerTypeEnum.CREATOR,
creator_ids="abc",
crawler_max_sleep_sec=7,
enable_ip_proxy=True,
ip_proxy_provider_name="static",
ip_proxy_pool_count=5,
static_proxy_url="http://127.0.0.1:8888",
)
)
joined = " ".join(command)
assert "--crawler_max_sleep_sec 7" in joined
assert "--enable_ip_proxy true" in joined
assert "--ip_proxy_provider_name static" in joined
assert "--static_proxy_url http://127.0.0.1:8888" in joined
def _frozen_clock(hour: int):
"""Stand-in for the datetime class whose now() is pinned to a given hour.
Testing an hour window by sleeping is not an option; patching the class the
scheduler imported is the whole mechanism.
"""
class _Frozen:
@staticmethod
def now(tz=None):
return datetime(2026, 1, 1, hour)
return _Frozen
class TestManualCrawlCookieFallback:
"""The crawl page no longer has its own paste box; it reuses Settings."""
@pytest_asyncio.fixture
async def captured(self, monkeypatch):
# api.services re-exports the singleton instance, not the module.
from api.services import crawler_manager
seen: dict = {}
async def _fake_start(request, extra_args=None):
seen["cookies"] = request.cookies
return True
monkeypatch.setattr(crawler_manager, "start", _fake_start)
return seen
@pytest.mark.asyncio
async def test_falls_back_to_the_stored_cookie(self, client, captured):
await client.put(
"/api/settings", json={"platform.xhs.cookie": "web_session=stored"}
)
response = await client.post(
"/api/crawler/start",
json={
"platform": "xhs",
"login_type": "cookie",
"crawler_type": "creator",
"creator_ids": "abc",
},
)
assert response.status_code == 200
assert captured["cookies"] == "web_session=stored"
@pytest.mark.asyncio
async def test_an_explicit_cookie_still_wins(self, client, captured):
await client.put(
"/api/settings", json={"platform.xhs.cookie": "web_session=stored"}
)
await client.post(
"/api/crawler/start",
json={
"platform": "xhs",
"login_type": "cookie",
"crawler_type": "creator",
"creator_ids": "abc",
"cookies": "web_session=explicit",
},
)
assert captured["cookies"] == "web_session=explicit"
@pytest.mark.asyncio
async def test_missing_cookie_is_a_clear_error_not_a_silent_failure(
self, client, captured
):
"""Better a 400 that names the fix than a run that fetches nothing."""
response = await client.post(
"/api/crawler/start",
json={
"platform": "xhs",
"login_type": "cookie",
"crawler_type": "creator",
"creator_ids": "abc",
},
)
assert response.status_code == 400
assert "设置" in response.json()["detail"]
assert "cookies" not in captured
@pytest.mark.asyncio
async def test_the_cookie_is_read_per_platform(self, client, captured):
await client.put(
"/api/settings",
params={"platform": "xhs"},
json={"platform.xhs.cookie": "web_session=xhs-only"},
)
# Douyin has no stored cookie, so it must not borrow Xiaohongshu's.
response = await client.post(
"/api/crawler/start",
json={
"platform": "dy",
"login_type": "cookie",
"crawler_type": "creator",
"creator_ids": "abc",
},
)
assert response.status_code == 400
class TestActiveHours:
"""The window gate lives in the scheduler, not the crawler."""
@pytest.mark.asyncio
async def test_inside_a_daytime_window(self, client, monkeypatch):
await client.put(
"/api/settings",
json={"system.active_hours_start": 8, "system.active_hours_end": 22},
)
monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(12))
async with monitor_db.get_session() as session:
assert await MonitorScheduler()._within_active_hours(session) is True
@pytest.mark.asyncio
async def test_outside_a_daytime_window(self, client, monkeypatch):
await client.put(
"/api/settings",
json={"system.active_hours_start": 8, "system.active_hours_end": 22},
)
monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(3))
async with monitor_db.get_session() as session:
assert await MonitorScheduler()._within_active_hours(session) is False
@pytest.mark.asyncio
async def test_window_wrapping_past_midnight(self, client, monkeypatch):
await client.put(
"/api/settings",
json={"system.active_hours_start": 22, "system.active_hours_end": 6},
)
for hour, expected in ((23, True), (3, True), (12, False)):
monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(hour))
async with monitor_db.get_session() as session:
assert await MonitorScheduler()._within_active_hours(session) is expected
@pytest.mark.asyncio
async def test_default_window_covers_the_whole_day(self, client, monkeypatch):
for hour in (0, 12, 23):
monkeypatch.setattr(scheduler_module, "datetime", _frozen_clock(hour))
async with monitor_db.get_session() as session:
assert await MonitorScheduler()._within_active_hours(session) is True