Files
MediaCrawler/api/monitor/app_settings.py
T
butubb e348de48d3
Deploy VitePress site to Pages / build (push) Waiting to run
Deploy VitePress site to Pages / Deploy (push) Blocked by required conditions
feat(upstream): 上游更新检查——定期比上游、落后了推企业微信
本仓库在上游 MediaCrawler 之上加了一整层(见 UPSTREAM.md),可合并流程默认
「有人知道上游动了」。而部署是 git pull --ff-only,只从自己的 Gitea 拉——上游的
提交不主动 fetch 就永远看不见。拖着不合并的代价是复利的:越久越难合。

于是把「上游动了没有」变成一条会自己跑、会推企业微信的通知:

* api/monitor/upstream.py:git fetch <url> <branch> 到 FETCH_HEAD,用
  rev-list --count HEAD..FETCH_HEAD 算落后数、FETCH_HEAD..HEAD 算领先数。
  用 git 而非托管商 API,因为只有 git 知道共同祖先在哪——本仓库含有上游没有的
  提交,直接比 tip 会得出错误结论。增量 fetch 只传几个新提交,不会遇到
  UPSTREAM.md 里说的「大包必断」。
* 只 fetch 到 FETCH_HEAD:不配 remote、不写 refs/remotes、不碰索引与工作区,
  所以不打断正在跑的采集,也不和 deploy.sh 的 git pull 抢锁。
* 挂在调度器 tick 上(不是采集,所以不看 is_busy、不受活跃时段限制——定时检查
  放在半夜反而最合适),按 checked_at + 间隔 到期才跑;失败也写 checked_at,
  于是 GitHub 不通时是每间隔重试一次,而不是每个 tick 撞一次墙。
* 同一个 tip 只推一次(记 tip 而不是「推过没」),上游真又动了会再推。
* 两个接口:GET /monitor/upstream 只读缓存;POST /monitor/upstream/check 手动
  查一次且刻意不推通知——点按钮的人正看着结果。
* 默认关闭,间隔默认一天。

Dockerfile 显式装 git(python:slim 不带,而这是唯一的依赖);deploy.sh 顺带补上
一个真 bug 的提示:Dockerfile/requirements.txt 变了只 up -d 用的还是旧镜像。
2026-10-10 09:17:06 +08:00

464 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright (c) 2025 [email protected]
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/app_settings.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""Application settings, declared once and rendered from that declaration.
Every setting carries a **scope**, which is the whole reason this is not a flat
list:
* ``platform`` -- each platform keeps its own copy. A cookie obviously differs,
but so do crawl pacing and proxies: what is safe on one platform is a rate
limit on another. Stored as ``platform.<p>.<name>``.
* ``system`` -- one value for the whole instance. The notification webhook is
a single group chat, and the scheduler has a single active-hours window, so
scoping those per platform would be a fiction.
The registry is the single source of truth: the API returns it and the Settings
page builds its form from it, so adding a setting does not mean editing a
matching list on the frontend.
Two rules carry over from how the cookie and webhook were already handled:
* **Secrets are never returned.** A sensitive key comes back as
``{present, length, updated_at}``, never as a value.
* **Update is partial.** Only keys present in the request are written, so a form
that does not resubmit a secret cannot silently wipe it.
"""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from .platforms import PLATFORM_XHS
from .settings import (
delete_setting,
get_setting,
platform_key,
set_setting,
system_key,
)
from .upstream import DEFAULT_BRANCH, DEFAULT_REMOTE_URL
SCOPE_PLATFORM = "platform"
SCOPE_SYSTEM = "system"
TYPE_BOOL = "bool"
TYPE_INT = "int"
TYPE_STR = "str"
TYPE_SECRET = "secret"
# Mirrors config/base_config.py. Nothing is written until the operator changes
# something; an unset value simply means "pass no CLI flag, so the config file's
# value applies".
_DEFAULT_SLEEP_SEC = 2
@dataclass
class SettingSpec:
name: str
scope: str
type: str
label: str
help: str = ""
default: Any = None
minimum: Optional[int] = None
maximum: Optional[int] = None
choices: Optional[List[str]] = None
affects_new_runs: bool = True
def key(self, platform: str = PLATFORM_XHS) -> str:
if self.scope == SCOPE_SYSTEM:
return system_key(self.name)
return platform_key(platform, self.name)
SETTING_SPECS: List[SettingSpec] = [
# --- 平台设置 -----------------------------------------------------------
SettingSpec(
name="cookie",
scope=SCOPE_PLATFORM,
type=TYPE_SECRET,
label="登录 Cookie",
help="定时监控必须持久化登录态。建议先手动登录一次再粘贴 Cookie。",
),
SettingSpec(
name="default_interval_minutes",
scope=SCOPE_PLATFORM,
type=TYPE_INT,
label="新任务默认采集间隔(分钟)",
help="仅影响新建任务时的默认值,不会改动已有任务。",
default=360,
minimum=30,
maximum=10080,
),
SettingSpec(
name="default_max_notes",
scope=SCOPE_PLATFORM,
type=TYPE_INT,
label="默认单轮作品上限",
default=20,
minimum=1,
maximum=500,
),
SettingSpec(
name="default_max_comments",
scope=SCOPE_PLATFORM,
type=TYPE_INT,
label="默认每篇评论抓取条数",
help="接口无时间排序,只取平台默认排序的前 N 条;N 越大越容易发现新评论。",
default=50,
minimum=1,
maximum=500,
),
SettingSpec(
name="enable_sub_comments",
scope=SCOPE_PLATFORM,
type=TYPE_BOOL,
label="抓取二级评论",
help="请求量显著增加,风控风险更高。",
default=False,
),
SettingSpec(
name="crawl_sleep_sec",
scope=SCOPE_PLATFORM,
type=TYPE_INT,
label="请求间隔(秒)",
help="调大更慢但更不容易触发平台限流。各平台风控容忍度不同,故分开配置。",
default=_DEFAULT_SLEEP_SEC,
minimum=0,
maximum=600,
),
SettingSpec(
name="enable_ip_proxy",
scope=SCOPE_PLATFORM,
type=TYPE_BOOL,
label="启用 IP 代理",
default=False,
),
SettingSpec(
name="proxy_provider",
scope=SCOPE_PLATFORM,
type=TYPE_STR,
label="代理提供方",
default="kuaidaili",
choices=["kuaidaili", "wandouhttp", "static"],
),
SettingSpec(
name="proxy_pool_count",
scope=SCOPE_PLATFORM,
type=TYPE_INT,
label="代理 IP 池大小",
default=2,
minimum=1,
maximum=100,
),
SettingSpec(
name="static_proxy_url",
scope=SCOPE_PLATFORM,
type=TYPE_STR,
label="静态代理地址",
help="仅当提供方选择 static 时使用,格式 http://host:port",
default="",
),
# --- 系统设置 -----------------------------------------------------------
SettingSpec(
name="wecom_webhook",
scope=SCOPE_SYSTEM,
type=TYPE_SECRET,
label="企业微信 Webhook",
help="企业微信群机器人地址。所有平台共用同一个群,只有开了推送开关的任务才会发消息。",
),
SettingSpec(
name="active_hours_start",
scope=SCOPE_SYSTEM,
type=TYPE_INT,
label="活跃时段开始(小时)",
help="只在此时段内触发定时采集。默认 0–23 即全天;支持跨午夜,如 22–6。",
default=0,
minimum=0,
maximum=23,
affects_new_runs=False,
),
SettingSpec(
name="active_hours_end",
scope=SCOPE_SYSTEM,
type=TYPE_INT,
label="活跃时段结束(小时)",
default=23,
minimum=0,
maximum=23,
affects_new_runs=False,
),
SettingSpec(
name="cdp_enabled",
scope=SCOPE_SYSTEM,
type=TYPE_BOOL,
label="接管已有 Chrome(CDP)",
help=(
"开启后爬虫不再自己启动浏览器,而是接管本机已开放远程调试端口的 Chrome"
"(默认 127.0.0.1:9222),复用它的登录态与扩展。"
"服务器部署请开启;本机桌面使用请保持关闭。"
),
default=False,
),
# --- 上游更新检查 -------------------------------------------------------
# 这几项不作用于采集,所以都标 affects_new_runs=False:改动它们不需要等下一轮,
# 也不影响采集命令的拼装。
SettingSpec(
name="upstream_check_enabled",
scope=SCOPE_SYSTEM,
type=TYPE_BOOL,
label="检查上游仓库更新",
help=(
"定期 fetch 上游仓库,看看它有没有新提交,并在有更新时推送通知。"
"本仓库在上游之上加了一整层(见 UPSTREAM.md),不定期看一眼就会越拖越难合并。"
),
default=False,
affects_new_runs=False,
),
SettingSpec(
name="upstream_check_interval_minutes",
scope=SCOPE_SYSTEM,
type=TYPE_INT,
label="上游检查间隔(分钟)",
help="默认 1440 分钟(每天一次)。检查只是 fetch,不需要太频繁。",
default=1440,
minimum=30,
maximum=10080,
affects_new_runs=False,
),
SettingSpec(
name="upstream_remote_url",
scope=SCOPE_SYSTEM,
type=TYPE_STR,
label="上游仓库地址",
help=(
"默认是 GitHub 上的上游。国内直连 GitHub 不稳时改成 gitcode 镜像"
"(见 UPSTREAM.md),或任意能访问到上游的地址。"
),
default=DEFAULT_REMOTE_URL,
affects_new_runs=False,
),
SettingSpec(
name="upstream_branch",
scope=SCOPE_SYSTEM,
type=TYPE_STR,
label="上游分支",
default=DEFAULT_BRANCH,
affects_new_runs=False,
),
SettingSpec(
name="upstream_notify",
scope=SCOPE_SYSTEM,
type=TYPE_BOOL,
label="上游有更新时推送通知",
help="只在出现此前没推过的上游提交时发一条,同一个更新不会反复推。",
default=True,
affects_new_runs=False,
),
]
SPECS_BY_NAME = {spec.name: spec for spec in SETTING_SPECS}
# Managed by their own endpoints; never writable through the settings API.
# Suffix-matched rather than enumerated, because the cookie bookkeeping keys
# exist once per platform.
_HIDDEN_KEY_SUFFIXES = (".cookie_updated_at", ".cookie_last_ok_at")
_HIDDEN_KEYS = {"auth_password_hash", "auth_password_updated_at"}
def _is_hidden(key: str) -> bool:
return key in _HIDDEN_KEYS or key.endswith(_HIDDEN_KEY_SUFFIXES)
class SettingValidationError(ValueError):
"""Raised for a value the registry will not accept."""
def _coerce(spec: SettingSpec, raw: Any) -> Any:
if spec.type == TYPE_SECRET:
return str(raw) if raw is not None else ""
if spec.type == TYPE_BOOL:
if isinstance(raw, bool):
return raw
text = str(raw).strip().lower()
if text in ("1", "true", "yes", "y", "on"):
return True
if text in ("0", "false", "no", "n", "off", ""):
return False
raise SettingValidationError(f"{spec.label}: 需要是/否")
if spec.type == TYPE_INT:
try:
value = int(raw)
except (TypeError, ValueError):
raise SettingValidationError(f"{spec.label}: 需要整数")
if spec.minimum is not None and value < spec.minimum:
raise SettingValidationError(f"{spec.label}: 不能小于 {spec.minimum}")
if spec.maximum is not None and value > spec.maximum:
raise SettingValidationError(f"{spec.label}: 不能大于 {spec.maximum}")
return value
value = str(raw) if raw is not None else ""
if spec.choices and value not in spec.choices:
raise SettingValidationError(f"{spec.label}: 只能是 {'/'.join(spec.choices)}")
return value
def _decode(spec: SettingSpec, raw: Optional[str]) -> Any:
if raw is None:
return spec.default
if spec.type == TYPE_BOOL:
return raw.strip().lower() in ("1", "true", "yes", "y", "on")
if spec.type == TYPE_INT:
try:
return int(raw)
except ValueError:
return spec.default
return raw
def _encode(spec: SettingSpec, value: Any) -> str:
if spec.type == TYPE_BOOL:
return "true" if value else "false"
return str(value)
def _describe(spec: SettingSpec, platform: str) -> Dict[str, Any]:
return {
"key": spec.key(platform),
"name": spec.name,
"scope": spec.scope,
"type": spec.type,
"label": spec.label,
"help": spec.help,
"default": spec.default,
"minimum": spec.minimum,
"maximum": spec.maximum,
"choices": spec.choices,
"affects_new_runs": spec.affects_new_runs,
}
async def get_all(session: AsyncSession, platform: str = PLATFORM_XHS) -> Dict[str, Any]:
"""Every editable setting for one platform, plus the system-wide ones.
Secrets come back masked, never in the clear.
"""
values: Dict[str, Any] = {}
secrets: Dict[str, Any] = {}
for spec in SETTING_SPECS:
key = spec.key(platform)
raw = await get_setting(session, key)
if spec.type == TYPE_SECRET:
secrets[key] = {"present": bool(raw), "length": len(raw or "")}
else:
values[key] = _decode(spec, raw)
return {
"platform": platform,
"values": values,
"secrets": secrets,
"specs": [_describe(spec, platform) for spec in SETTING_SPECS],
}
def _spec_for_key(key: str, platform: str) -> Optional[SettingSpec]:
"""Resolve a full key back to its spec, rejecting keys for another platform."""
for spec in SETTING_SPECS:
if spec.key(platform) == key:
return spec
return None
async def update(
session: AsyncSession, payload: Dict[str, Any], platform: str = PLATFORM_XHS
) -> List[str]:
"""Apply a partial update. Returns the keys that changed.
Only keys present in ``payload`` are touched: a form that omits a secret must
not blank it. Keys belonging to a different platform are rejected rather than
silently written somewhere unexpected.
"""
changed: List[str] = []
for key, raw in payload.items():
if _is_hidden(key):
continue
spec = _spec_for_key(key, platform)
if spec is None:
raise SettingValidationError(f"未知的设置项:{key}")
# An explicit empty string clears a secret -- that is how the UI removes
# one. For everything else it is just a value.
if spec.type == TYPE_SECRET and raw == "":
await delete_setting(session, key)
changed.append(key)
continue
value = _coerce(spec, raw)
await set_setting(session, key, _encode(spec, value))
changed.append(key)
return changed
async def get_value(
session: AsyncSession,
name: str,
platform: str = PLATFORM_XHS,
fallback: Any = None,
) -> Any:
"""Read one typed setting for internal callers (the runner, the scheduler)."""
spec = SPECS_BY_NAME.get(name)
if spec is None:
return fallback
raw = await get_setting(session, spec.key(platform))
if raw is None:
return spec.default if fallback is None else fallback
return _decode(spec, raw)
async def defaults(session: AsyncSession, platform: str = PLATFORM_XHS) -> Dict[str, Any]:
"""Defaults applied when creating a task on this platform.
This is what makes the Settings page govern new tasks: the create endpoint
falls back to these for anything the caller omits.
"""
return {
"interval_minutes": int(
await get_value(session, "default_interval_minutes", platform, 360)
),
"max_notes_count": int(await get_value(session, "default_max_notes", platform, 20)),
"max_comments_count": int(
await get_value(session, "default_max_comments", platform, 50)
),
}
async def active_hours(session: AsyncSession) -> tuple[int, int]:
"""The (start, end) hour window for scheduled runs. System-wide."""
start = await get_value(session, "active_hours_start", fallback=0)
end = await get_value(session, "active_hours_end", fallback=23)
return int(start), int(end)