feat(upstream): 上游更新检查——定期比上游、落后了推企业微信
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

本仓库在上游 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 用的还是旧镜像。
This commit is contained in:
2026-10-10 09:17:06 +08:00
parent 44cbe8e2aa
commit e348de48d3
15 changed files with 1183 additions and 6 deletions
+355
View File
@@ -0,0 +1,355 @@
# -*- 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/upstream.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#
# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
"""上游仓库更新检查。
本仓库在上游(NanmiCoder/MediaCrawler)之上加了一整层监控/鉴权/多平台面板,
合并流程写在 UPSTREAM.md 里。但那份流程默认**有人知道上游动了**——而部署脚本是
`git pull --ff-only`,只从我们自己的 Gitea 拉,上游的提交不主动去 fetch 就永远
看不见。拖着不合并的代价是复利的:越久越难合,最后只能放弃。这个模块把「上游动
了没有」变成一条可定时、会推到企业微信的通知。
三处刻意的取舍:
* **用 git 而不是托管商的 HTTP API。** 只有 git 算得出「落后几个提交」:托管商
API 能告诉你上游 tip 是什么,但它不知道我们与上游的共同祖先在哪,而分歧点恰恰
是真正要合的东西。本仓库还含有上游没有的提交,直接比 tip 会得出错误的结论。
* **按 URL fetch 到 FETCH_HEAD,不配置 remote、不写 refs/remotes。** 服务器上的
checkout 是从 Gitea 克隆的,本来就没有 upstream 这个 remote;用 URL 直取就不必
先去改它的 git 配置。顺带也避免往别人的部署里塞一个 remote。
* **只读不写工作区。** fetch 只落对象和 FETCH_HEAD,不碰索引与工作区,所以不会打断
正在跑的采集,也不会和 `./deploy.sh` 的 git pull 抢锁。
依赖一个外部命令:**git**。本机开发环境一定有;容器里是 Dockerfile 显式装的
(python:3.11-slim 默认不带)。
"""
import asyncio
import json
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from tools.time_util import get_current_timestamp
from .db import get_session
from .models import SETTING_UPSTREAM_NOTIFIED_TIP, SETTING_UPSTREAM_STATE
from .settings import get_setting, set_setting
PROJECT_ROOT = Path(__file__).parent.parent.parent
# 默认就是本仓库跟踪的那个上游。国内直连 GitHub 不稳时改成 gitcode 镜像即可
# (见 UPSTREAM.md「直连 GitHub 不通时」)。
DEFAULT_REMOTE_URL = "https://github.com/NanmiCoder/MediaCrawler.git"
DEFAULT_BRANCH = "main"
# fetch 要走网络,给宽松些;其余全是本地命令,慢到这个程度只能说明仓库坏了。
FETCH_TIMEOUT_SECONDS = 120
LOCAL_TIMEOUT_SECONDS = 20
# 通知里最多列几条提交。要传达的是「该动手了」,不是把 changelog 搬到群里。
MAX_LISTED_COMMITS = 10
# 状态里留几条给前端展示。比通知多留一些,界面上能看到更完整的列表。
MAX_STORED_COMMITS = 30
# git log 用 Unit Separator 分隔字段:它不可能出现在提交信息里,比制表符安全。
_RECORD_SEPARATOR = "\x1f"
_LOG_FORMAT = (
f"%h{_RECORD_SEPARATOR}%an{_RECORD_SEPARATOR}%ad{_RECORD_SEPARATOR}%s"
)
class GitError(RuntimeError):
"""git 不可用,或某条 git 命令失败了。"""
@dataclass(frozen=True)
class Commit:
"""一条上游提交,只留通知/展示需要的四个字段。"""
sha: str
author: str
date: str
subject: str
@dataclass
class CheckResult:
"""一次检查的结论。失败也是一种结论,用 ``ok``/``error`` 表达而不是抛异常。"""
ok: bool
# HEAD..FETCH_HEAD:上游有而我们没有的提交数 —— 要合的就是这些。
behind: int = 0
# FETCH_HEAD..HEAD:我们有自己的提交数 —— 也就是这一层的规模。
ahead: int = 0
tip: str = ""
head: str = ""
commits: List[Commit] = field(default_factory=list)
error: str = ""
def as_dict(self) -> Dict[str, Any]:
return {
"ok": self.ok,
"behind": self.behind,
"ahead": self.ahead,
"tip": self.tip,
"head": self.head,
"commits": [commit.__dict__ for commit in self.commits],
"error": self.error,
}
def _git(args: List[str], timeout: int) -> subprocess.CompletedProcess:
env = dict(os.environ)
# 远端要凭据时(地址写成了私有仓库),git 会停下来问密码,而这里没有终端可问,
# 于是挂到超时。关掉一切交互,让它立刻失败。
env["GIT_TERMINAL_PROMPT"] = "0"
env["GIT_ASKPASS"] = ""
env["SSH_ASKPASS"] = ""
# 容器里 uid 1000 没有 passwd 项,git 找不到 HOME 会抱怨。给一个存在且可写的。
env.setdefault("HOME", "/tmp")
return subprocess.run(
[
"git",
"-C",
str(PROJECT_ROOT),
# 只对自己这个 checkout 放行所有权检查。容器里 uid 一般与属主一致,
# 但 bind mount 的属主未必,一旦不一致 git 会直接拒绝干任何活。
"-c",
f"safe.directory={PROJECT_ROOT}",
# 忽略任何全局凭据助手:这是个只读的公开仓库,不该去翻钥匙串。
"-c",
"credential.helper=",
*args,
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
env=env,
)
def _run(args: List[str], timeout: int) -> Tuple[int, str, str]:
"""跑一条 git 命令,返回 (returncode, stdout, stderr)。
只把「跑不起来」当异常;命令返回非零是正常结果,交给调用方处理。
"""
try:
proc = _git(args, timeout)
except FileNotFoundError as exc:
raise GitError("未找到 git 命令,请先安装 git") from exc
except subprocess.TimeoutExpired as exc:
raise GitError(f"git {args[0]} 超时({timeout} 秒)") from exc
return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip()
def _require(args: List[str], timeout: int, what: str) -> str:
code, out, err = _run(args, timeout)
if code != 0:
# git 的报错通常是多行的,只留第一行;完整输出塞进日志反而更难读。
detail = err.splitlines()[0].strip() if err else "未知错误"
raise GitError(f"{what}:{detail}")
return out
def _to_int(raw: str) -> int:
try:
return int(raw)
except (TypeError, ValueError):
return 0
def _parse_log(raw: str) -> List[Commit]:
commits: List[Commit] = []
for line in raw.splitlines():
parts = line.split(_RECORD_SEPARATOR)
if len(parts) != 4:
# 格式不对就跳过这一条:一条读不出来的提交不该让整次检查失败。
continue
sha, author, date, subject = parts
commits.append(Commit(sha=sha, author=author, date=date, subject=subject))
return commits
def _check_sync(remote_url: str, branch: str) -> CheckResult:
"""阻塞实现,异步包装见 :func:`check`。"""
# 在 try 之前绑定:后面的失败结果也带上它 —— 「检查失败」时当前跑的是哪个
# 提交,正是排查时第一个想知道的。
head = ""
try:
head = _require(["rev-parse", "HEAD"], LOCAL_TIMEOUT_SECONDS, "读取本地 HEAD 失败")
# 增量 fetch:对象本地基本都已经有了,所以正常情况下只传几个新提交,
# 不会遇到 UPSTREAM.md 里说的「大包必断」。
_require(
["fetch", "--no-tags", remote_url, branch],
FETCH_TIMEOUT_SECONDS,
"从上游 fetch 失败",
)
tip = _require(["rev-parse", "FETCH_HEAD"], LOCAL_TIMEOUT_SECONDS, "读不到 FETCH_HEAD")
behind = _to_int(
_require(
["rev-list", "--count", "HEAD..FETCH_HEAD"],
LOCAL_TIMEOUT_SECONDS,
"统计落后提交数失败",
)
)
ahead = _to_int(
_require(
["rev-list", "--count", "FETCH_HEAD..HEAD"],
LOCAL_TIMEOUT_SECONDS,
"统计领先提交数失败",
)
)
# 只在确实落后时才读提交列表:已经是最新时这条 git log 毫无意义。
raw_log = ""
if behind:
raw_log = _require(
[
"log",
f"--max-count={MAX_STORED_COMMITS}",
"--date=short",
f"--format={_LOG_FORMAT}",
"HEAD..FETCH_HEAD",
],
LOCAL_TIMEOUT_SECONDS,
"读取新提交列表失败",
)
except GitError as exc:
return CheckResult(ok=False, head=head, error=str(exc))
return CheckResult(
ok=True,
behind=behind,
ahead=ahead,
tip=tip,
head=head,
commits=_parse_log(raw_log),
)
async def check(
remote_url: str = DEFAULT_REMOTE_URL, branch: str = DEFAULT_BRANCH
) -> CheckResult:
"""跑一次检查。网络与子进程都丢进线程,事件循环不被阻塞。
不抛异常:上游不通是常态(尤其是直连 GitHub),那也是一种要记录下来的结果。
"""
return await asyncio.to_thread(_check_sync, remote_url, branch)
def build_message(result: CheckResult, branch: str) -> str:
"""把一次「上游有新提交」的结果写成一条企业微信 markdown。"""
lines = [
"**🔔 上游 MediaCrawler 有更新**",
f"> 当前部署落后 `{branch}` **{result.behind}** 个提交",
]
if result.ahead:
lines.append(f"> (本仓库另有 {result.ahead} 个自己的提交,合并时注意保留)")
for commit in result.commits[:MAX_LISTED_COMMITS]:
lines.append(f"> `{commit.sha}` {commit.subject}")
# 落后数可能大于列出来的条数:状态里留的提交本身也是截断的(30 条),
# 所以这里比的是总数,不是 len(commits)。
if result.behind > MAX_LISTED_COMMITS:
lines.append(f"> …等共 {result.behind} 个提交")
lines.append("> 合并步骤见仓库根目录 `UPSTREAM.md`")
return "\n".join(lines)
async def load_state(session: AsyncSession) -> Dict[str, Any]:
"""最近一次检查的结果,从设置里读回来。没查过时是空字典。"""
raw = await get_setting(session, SETTING_UPSTREAM_STATE)
if not raw:
return {}
try:
state = json.loads(raw)
except json.JSONDecodeError:
# 手改坏了的行不该让接口 500,当作「没查过」即可。
return {}
return state if isinstance(state, dict) else {}
async def _save_state(session: AsyncSession, state: Dict[str, Any]) -> None:
# 整体读写,所以存成一条 JSON:拆成多个 key 只会带来写到一半的不一致。
await set_setting(session, SETTING_UPSTREAM_STATE, json.dumps(state, ensure_ascii=False))
async def run_check(notify_when_new: bool = True) -> Dict[str, Any]:
"""检查一次,落库,必要时推送。返回值可直接交给前端。
分三段各自的数据库会话:fetch 最长可能跑满两分钟,占着一个连接不合适 ——
理由与 runner.py 的分段完全相同。
"""
# 延迟导入:app_settings 在模块级 import 本模块(为了那个默认地址常量),
# 模块级反向 import 会成环。
from . import app_settings, notify
async with get_session() as session:
remote_url = str(
await app_settings.get_value(
session, "upstream_remote_url", fallback=DEFAULT_REMOTE_URL
)
or DEFAULT_REMOTE_URL
)
branch = str(
await app_settings.get_value(session, "upstream_branch", fallback=DEFAULT_BRANCH)
or DEFAULT_BRANCH
)
notify_enabled = bool(
await app_settings.get_value(session, "upstream_notify", fallback=True)
)
notified_tip = (await get_setting(session, SETTING_UPSTREAM_NOTIFIED_TIP)) or ""
webhook_url = await notify.get_webhook_url(session)
result = await check(remote_url, branch)
payload: Dict[str, Any] = {
"checked_at": get_current_timestamp(),
"remote_url": remote_url,
"branch": branch,
**result.as_dict(),
}
async with get_session() as session:
await _save_state(session, payload)
has_update = result.ok and result.behind > 0 and bool(result.tip)
# 同一个 tip 只推一次:否则每过一个检查周期就把同样的更新推到群里,
# 直到有人去合为止。上游真又动了(tip 变了)时应该再推。
if (
notify_when_new
and notify_enabled
and has_update
and webhook_url
and result.tip != notified_tip
):
ok, detail = await notify.send_wecom(webhook_url, build_message(result, branch))
if ok:
await set_setting(session, SETTING_UPSTREAM_NOTIFIED_TIP, result.tip)
payload["notified"] = True
else:
# 推送失败不该抹掉检查结果 —— 界面上仍然能看到「落后几个提交」。
payload["notify_error"] = detail
return payload