本仓库在上游 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 用的还是旧镜像。
356 lines
14 KiB
Python
356 lines
14 KiB
Python
# -*- 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
|