nonebot-plugin-github-release 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_github_release/__init__.py +69 -0
- nonebot_plugin_github_release/card.py +352 -0
- nonebot_plugin_github_release/commands.py +258 -0
- nonebot_plugin_github_release/config.py +70 -0
- nonebot_plugin_github_release/latest.py +60 -0
- nonebot_plugin_github_release/models.py +64 -0
- nonebot_plugin_github_release/plan.py +47 -0
- nonebot_plugin_github_release/render.py +174 -0
- nonebot_plugin_github_release/source.py +170 -0
- nonebot_plugin_github_release/store.py +147 -0
- nonebot_plugin_github_release/versions.py +52 -0
- nonebot_plugin_github_release/watch.py +198 -0
- nonebot_plugin_github_release-0.1.0.dist-info/METADATA +109 -0
- nonebot_plugin_github_release-0.1.0.dist-info/RECORD +16 -0
- nonebot_plugin_github_release-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_github_release-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""插件配置,全部可用 `.env` 里的 `GH_WATCH_*` 覆盖。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Config(BaseModel):
|
|
11
|
+
"""配置项。"""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="ignore")
|
|
14
|
+
|
|
15
|
+
# 默认监控并推送的仓库(owner/repo)。群里也能用 /gh订阅 动态加
|
|
16
|
+
gh_watch_repos: List[str] = Field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
# 默认接收推送的群。与上面的仓库做组合:每个群都订阅每个仓库
|
|
19
|
+
gh_watch_groups: List[int] = Field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
# 轮询间隔(分钟)
|
|
22
|
+
gh_watch_interval_minutes: int = 10
|
|
23
|
+
|
|
24
|
+
# 展示时间使用的时区
|
|
25
|
+
gh_watch_timezone: str = "Asia/Shanghai"
|
|
26
|
+
|
|
27
|
+
# 是否连 alpha / beta / rc 一起推
|
|
28
|
+
gh_watch_include_prerelease: bool = True
|
|
29
|
+
|
|
30
|
+
# 没有正文的 release 不算公告,不推
|
|
31
|
+
gh_watch_require_body: bool = True
|
|
32
|
+
|
|
33
|
+
# GitHub Token(可选,提高 API 限额)
|
|
34
|
+
gh_watch_github_token: str = ""
|
|
35
|
+
|
|
36
|
+
# 代理(可选,留空则沿用环境变量)
|
|
37
|
+
gh_watch_proxy: str = ""
|
|
38
|
+
|
|
39
|
+
# 文本推送的正文上限
|
|
40
|
+
gh_watch_max_chars: int = 600
|
|
41
|
+
|
|
42
|
+
# /gh公告 查询时的正文上限
|
|
43
|
+
gh_watch_query_max_chars: int = 1200
|
|
44
|
+
|
|
45
|
+
# 每个仓库每轮最多推几条
|
|
46
|
+
gh_watch_max_per_round: int = 3
|
|
47
|
+
|
|
48
|
+
# 正文里的 @贡献者 是否去掉(只影响文本推送,卡片里保留)
|
|
49
|
+
gh_watch_strip_mentions: bool = True
|
|
50
|
+
|
|
51
|
+
# 中英双语公告只保留中文段(值改成 all 则两段都保留)
|
|
52
|
+
gh_watch_body_language: str = "zh"
|
|
53
|
+
|
|
54
|
+
# 是否发送渲染好的卡片图片(需要 nonebot-plugin-htmlrender + chromium)
|
|
55
|
+
gh_watch_send_image: bool = True
|
|
56
|
+
|
|
57
|
+
gh_watch_card_width: int = 900
|
|
58
|
+
gh_watch_card_max_height: int = 1500
|
|
59
|
+
gh_watch_card_max_chars: int = 2400
|
|
60
|
+
gh_watch_card_show_reactions: bool = True
|
|
61
|
+
|
|
62
|
+
# 数据目录,默认交给 nonebot-plugin-localstore 管
|
|
63
|
+
gh_watch_data_dir: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config() -> Config:
|
|
67
|
+
"""从 NoneBot 全局配置读取插件配置。"""
|
|
68
|
+
import nonebot
|
|
69
|
+
|
|
70
|
+
return nonebot.get_plugin_config(Config)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""按需查询公告(给 /gh公告 用):只读不写,带短时缓存。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, List, Tuple
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .models import Release
|
|
10
|
+
from .plan import select_releases
|
|
11
|
+
from .render import format_local_time
|
|
12
|
+
from .source import fetch_releases
|
|
13
|
+
|
|
14
|
+
_CACHE_TTL_SECONDS = 60.0
|
|
15
|
+
_cache: Dict[str, Tuple[float, List[Release], str]] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cache_key(cfg: Config, repo: str) -> str:
|
|
19
|
+
return "|".join(
|
|
20
|
+
[repo, str(cfg.gh_watch_include_prerelease), str(cfg.gh_watch_require_body)]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def clear_cache() -> None:
|
|
25
|
+
_cache.clear()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def fetch_announcements(
|
|
29
|
+
cfg: Config, repo: str, *, use_cache: bool = True
|
|
30
|
+
) -> Tuple[List[Release], str]:
|
|
31
|
+
"""拉取某个仓库的 release 列表(按发布时间升序),返回 (列表, 数据源)。"""
|
|
32
|
+
key = _cache_key(cfg, repo)
|
|
33
|
+
now = time.monotonic()
|
|
34
|
+
if use_cache:
|
|
35
|
+
cached = _cache.get(key)
|
|
36
|
+
if cached is not None and now - cached[0] < _CACHE_TTL_SECONDS:
|
|
37
|
+
return cached[1], cached[2]
|
|
38
|
+
|
|
39
|
+
fetched = await fetch_releases(cfg, repo)
|
|
40
|
+
releases = select_releases(
|
|
41
|
+
fetched.releases,
|
|
42
|
+
include_prerelease=cfg.gh_watch_include_prerelease,
|
|
43
|
+
require_body=cfg.gh_watch_require_body,
|
|
44
|
+
)
|
|
45
|
+
_cache[key] = (now, releases, fetched.source)
|
|
46
|
+
return releases, fetched.source
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_list(releases: List[Release], *, repo: str, tz_name: str) -> str:
|
|
50
|
+
"""把若干条 release 渲染成简表。"""
|
|
51
|
+
lines = [f"【{repo} 最近 {len(releases)} 条】"]
|
|
52
|
+
for release in releases:
|
|
53
|
+
stage = "预发布" if release.prerelease else "正式版"
|
|
54
|
+
when = format_local_time(release.published_at, tz_name)
|
|
55
|
+
lines.append(f"· {release.title}({stage}|{when})")
|
|
56
|
+
if release.url:
|
|
57
|
+
lines.append(f" {release.url}")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append(f"发 /gh公告 {repo} 看最新一条全文")
|
|
60
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""数据模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_time(value: Any) -> datetime:
|
|
11
|
+
"""解析 GitHub 返回的时间,失败时退化为当前时间(UTC)。"""
|
|
12
|
+
if isinstance(value, str) and value:
|
|
13
|
+
try:
|
|
14
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
15
|
+
except ValueError:
|
|
16
|
+
pass
|
|
17
|
+
return datetime.now(timezone.utc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Release:
|
|
22
|
+
"""一条 GitHub Release。"""
|
|
23
|
+
|
|
24
|
+
tag: str
|
|
25
|
+
name: str
|
|
26
|
+
url: str
|
|
27
|
+
body: str
|
|
28
|
+
published_at: datetime
|
|
29
|
+
prerelease: bool
|
|
30
|
+
draft: bool = False
|
|
31
|
+
# 以下字段只有走 GitHub API 时才有;atom 源拿不到,用于渲染卡片
|
|
32
|
+
author_login: str = ""
|
|
33
|
+
author_avatar_url: str = ""
|
|
34
|
+
reactions: Optional[Dict[str, int]] = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def title(self) -> str:
|
|
38
|
+
return self.name or self.tag
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_github(cls, data: dict) -> Optional["Release"]:
|
|
42
|
+
tag = str(data.get("tag_name") or "").strip()
|
|
43
|
+
if not tag:
|
|
44
|
+
return None
|
|
45
|
+
author = data.get("author") or {}
|
|
46
|
+
raw_reactions = data.get("reactions") or {}
|
|
47
|
+
reactions = {
|
|
48
|
+
str(key): int(value)
|
|
49
|
+
for key, value in raw_reactions.items()
|
|
50
|
+
if isinstance(value, int)
|
|
51
|
+
} or None
|
|
52
|
+
return cls(
|
|
53
|
+
tag=tag,
|
|
54
|
+
name=str(data.get("name") or tag).strip(),
|
|
55
|
+
url=str(data.get("html_url") or "").strip(),
|
|
56
|
+
body=str(data.get("body") or ""),
|
|
57
|
+
# 草稿没有 published_at,退回 created_at
|
|
58
|
+
published_at=parse_time(data.get("published_at") or data.get("created_at")),
|
|
59
|
+
prerelease=bool(data.get("prerelease")),
|
|
60
|
+
draft=bool(data.get("draft")),
|
|
61
|
+
author_login=str(author.get("login") or ""),
|
|
62
|
+
author_avatar_url=str(author.get("avatar_url") or ""),
|
|
63
|
+
reactions=reactions,
|
|
64
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""纯逻辑:筛选要推的 release、算待推送列表。不依赖 NoneBot,方便单测。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
from .models import Release
|
|
8
|
+
from .store import RepoState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def select_releases(
|
|
12
|
+
releases: Sequence[Release],
|
|
13
|
+
*,
|
|
14
|
+
include_prerelease: bool = True,
|
|
15
|
+
require_body: bool = True,
|
|
16
|
+
) -> List[Release]:
|
|
17
|
+
"""挑出需要推送的 release,按发布时间升序。
|
|
18
|
+
|
|
19
|
+
- 草稿(draft)永远不推
|
|
20
|
+
- `require_body=True`(默认)时,没有正文的不算公告
|
|
21
|
+
- `include_prerelease=False` 时,alpha / beta / rc 一律不推
|
|
22
|
+
"""
|
|
23
|
+
selected: List[Release] = []
|
|
24
|
+
for release in releases:
|
|
25
|
+
if not release.tag or release.draft:
|
|
26
|
+
continue
|
|
27
|
+
if require_body and not release.body.strip():
|
|
28
|
+
continue
|
|
29
|
+
if not include_prerelease and release.prerelease:
|
|
30
|
+
continue
|
|
31
|
+
selected.append(release)
|
|
32
|
+
return sorted(selected, key=lambda release: release.published_at)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan(state: RepoState, releases: Sequence[Release]) -> Tuple[RepoState, List[Release]]:
|
|
36
|
+
"""算待推送列表并返回更新后的状态。
|
|
37
|
+
|
|
38
|
+
首次运行只把当前 release 记为基线(不补推历史);之后返回所有「还没推过的」。
|
|
39
|
+
"""
|
|
40
|
+
new_state = state.copy()
|
|
41
|
+
tags = {release.tag for release in releases}
|
|
42
|
+
if not state.initialized:
|
|
43
|
+
new_state.initialized = True
|
|
44
|
+
new_state.pushed = sorted(set(state.pushed) | tags)
|
|
45
|
+
return new_state, []
|
|
46
|
+
pushed = set(state.pushed)
|
|
47
|
+
return new_state, [release for release in releases if release.tag not in pushed]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""把 release 正文渲染成群消息文本。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from .models import Release
|
|
12
|
+
|
|
13
|
+
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
|
14
|
+
_HTML_HEADING_RE = re.compile(r"<h[1-6][^>]*>(.*?)</h[1-6]>", re.IGNORECASE | re.DOTALL)
|
|
15
|
+
_HTML_LI_RE = re.compile(r"<li\b[^>]*>", re.IGNORECASE)
|
|
16
|
+
_HTML_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
|
17
|
+
_HTML_BLOCK_RE = re.compile(
|
|
18
|
+
r"</?(?:p|div|ul|ol|section|article|blockquote|table|tr|td)\b[^>]*>",
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
# atom 源整篇可能挤在一行,先在块级标签后补换行(保留标签本身给后面的清洗用)
|
|
22
|
+
_HTML_BLOCK_BREAK_RE = re.compile(
|
|
23
|
+
r"</(?:p|div|h[1-6]|li|ul|ol|blockquote|section|article|tr|td)\s*>",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
_MD_HEADING_RE = re.compile(r"^#{1,6}\s*(.+?)\s*$")
|
|
27
|
+
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
28
|
+
_MENTION_RE = re.compile(r"(^|[\s。,、:;)】」!?!?])(@[A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
29
|
+
_BULLET_PREFIXES = ("- ", "* ", "+ ")
|
|
30
|
+
|
|
31
|
+
# 英文段落的起始标记(中英双语公告的分界)
|
|
32
|
+
_EN_ANCHOR_RE = re.compile(
|
|
33
|
+
r"""^\s*(?:
|
|
34
|
+
<h[1-6][^>]*id=["'](?:user-content-)?en(?:-[^"']*)?["'][^>]*>
|
|
35
|
+
| <h[1-6][^>]*>\s*(?:<a[^>]*>\s*)?english\b
|
|
36
|
+
| \#{1,6}\s*english\b
|
|
37
|
+
| \[english\]\(
|
|
38
|
+
)""",
|
|
39
|
+
re.IGNORECASE | re.VERBOSE,
|
|
40
|
+
)
|
|
41
|
+
# 顶部的语言切换行:[中文](#cn) | [English](#en),或剥掉标签后的「中文 | English」
|
|
42
|
+
_LANG_SWITCH_RE = re.compile(
|
|
43
|
+
r"^\s*\[?中文\]?(?:\([^)]*\))?\s*\|\s*\[?(?:English|EN)\]?(?:\([^)]*\))?\s*$",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def clean_body(
|
|
49
|
+
text: str, *, strip_mentions: bool = True, cut_foreign: bool = False
|
|
50
|
+
) -> str:
|
|
51
|
+
"""把 Markdown / HTML 清洗成适合展示的纯文本。
|
|
52
|
+
|
|
53
|
+
`cut_foreign=True` 时,遇到中英双语正文只保留前半部分(中文段),
|
|
54
|
+
并丢掉顶部的「中文 | English」切换行。
|
|
55
|
+
"""
|
|
56
|
+
if cut_foreign:
|
|
57
|
+
text = cut_foreign_section(text)
|
|
58
|
+
text = _HTML_BLOCK_BREAK_RE.sub(lambda match: match.group(0) + "\n", text or "")
|
|
59
|
+
text = _HTML_LI_RE.sub("\n· ", text)
|
|
60
|
+
text = _HTML_BR_RE.sub("\n", text)
|
|
61
|
+
text = _HTML_BLOCK_RE.sub("\n", text)
|
|
62
|
+
text = _HTML_HEADING_RE.sub(
|
|
63
|
+
lambda match: f"\n▍{_HTML_TAG_RE.sub('', match.group(1)).strip()}\n", text
|
|
64
|
+
)
|
|
65
|
+
text = _HTML_TAG_RE.sub("", text)
|
|
66
|
+
text = _MD_LINK_RE.sub(r"\1", text)
|
|
67
|
+
|
|
68
|
+
output: List[str] = []
|
|
69
|
+
for raw in text.splitlines():
|
|
70
|
+
line = raw.strip()
|
|
71
|
+
if not line:
|
|
72
|
+
output.append("")
|
|
73
|
+
continue
|
|
74
|
+
heading = _MD_HEADING_RE.match(line)
|
|
75
|
+
if heading is not None:
|
|
76
|
+
line = f"▍{heading.group(1)}"
|
|
77
|
+
elif line.startswith(_BULLET_PREFIXES):
|
|
78
|
+
line = "· " + line[2:].strip()
|
|
79
|
+
line = line.replace("`", "").replace("**", "")
|
|
80
|
+
if strip_mentions:
|
|
81
|
+
line = _MENTION_RE.sub(r"\1", line)
|
|
82
|
+
output.append(line.rstrip())
|
|
83
|
+
|
|
84
|
+
result = re.sub(r"\n{3,}", "\n\n", "\n".join(output)).strip()
|
|
85
|
+
# 中英双语公告用 --- 分隔,落在末尾就是噪音
|
|
86
|
+
return re.sub(r"(?:\n\s*(?:-{3,}|\*{3,}|_{3,})\s*)+\s*$", "", result).strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cut_foreign_section(body: str) -> str:
|
|
90
|
+
"""只保留中英双语正文的前半部分(中文段)。
|
|
91
|
+
|
|
92
|
+
必须在其它清洗之前调用——清洗会把 `<h3 id="en-x">English</h3>` 变成
|
|
93
|
+
`▍English`,那时就认不出分界了。
|
|
94
|
+
"""
|
|
95
|
+
body = _HTML_BLOCK_BREAK_RE.sub(lambda match: match.group(0) + "\n", body or "")
|
|
96
|
+
lines: List[str] = []
|
|
97
|
+
for raw in body.splitlines():
|
|
98
|
+
if _EN_ANCHOR_RE.match(raw.strip()):
|
|
99
|
+
break
|
|
100
|
+
plain = _HTML_TAG_RE.sub("", raw).strip()
|
|
101
|
+
if _LANG_SWITCH_RE.match(plain):
|
|
102
|
+
continue
|
|
103
|
+
lines.append(raw)
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def truncate_text(text: str, max_chars: int) -> str:
|
|
108
|
+
"""按行截断,尽量不破坏行结构。"""
|
|
109
|
+
if max_chars <= 0 or len(text) <= max_chars:
|
|
110
|
+
return text
|
|
111
|
+
kept: List[str] = []
|
|
112
|
+
total = 0
|
|
113
|
+
for line in text.splitlines():
|
|
114
|
+
if total + len(line) + 1 > max_chars:
|
|
115
|
+
break
|
|
116
|
+
kept.append(line)
|
|
117
|
+
total += len(line) + 1
|
|
118
|
+
if not kept:
|
|
119
|
+
return text[:max_chars].rstrip() + "…"
|
|
120
|
+
return "\n".join(kept).rstrip() + "\n…(正文较长,完整内容见下方链接)"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@lru_cache(maxsize=8)
|
|
124
|
+
def resolve_timezone(name: str) -> timezone:
|
|
125
|
+
"""解析时区名,失败退回 UTC(Windows 上需要 tzdata)。"""
|
|
126
|
+
try:
|
|
127
|
+
return ZoneInfo(name) # type: ignore[return-value]
|
|
128
|
+
except Exception:
|
|
129
|
+
return timezone.utc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_local_time(moment: Optional[datetime], tz_name: str) -> str:
|
|
133
|
+
if moment is None:
|
|
134
|
+
return "-"
|
|
135
|
+
if moment.tzinfo is None:
|
|
136
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
137
|
+
return moment.astimezone(resolve_timezone(tz_name)).strftime("%Y-%m-%d %H:%M")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def format_iso_local(value: Optional[str], tz_name: str) -> str:
|
|
141
|
+
if not value:
|
|
142
|
+
return "-"
|
|
143
|
+
try:
|
|
144
|
+
moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
145
|
+
except ValueError:
|
|
146
|
+
return value
|
|
147
|
+
return format_local_time(moment, tz_name)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_release(
|
|
151
|
+
release: Release,
|
|
152
|
+
*,
|
|
153
|
+
repo: str,
|
|
154
|
+
tz_name: str = "Asia/Shanghai",
|
|
155
|
+
max_chars: int = 600,
|
|
156
|
+
strip_mentions: bool = True,
|
|
157
|
+
cut_foreign: bool = True,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""渲染纯文本推送内容。"""
|
|
160
|
+
body = truncate_text(
|
|
161
|
+
clean_body(release.body, strip_mentions=strip_mentions, cut_foreign=cut_foreign),
|
|
162
|
+
max_chars,
|
|
163
|
+
)
|
|
164
|
+
stage = "预发布" if release.prerelease else "正式版"
|
|
165
|
+
|
|
166
|
+
parts = [f"【{repo} {stage}】{release.title}"]
|
|
167
|
+
published = format_local_time(release.published_at, tz_name)
|
|
168
|
+
if published != "-":
|
|
169
|
+
parts.append(f"发布时间:{published}")
|
|
170
|
+
if body:
|
|
171
|
+
parts.append(body)
|
|
172
|
+
if release.url:
|
|
173
|
+
parts.append(f"完整公告:{release.url}")
|
|
174
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""拉取 GitHub 仓库的 release。
|
|
2
|
+
|
|
3
|
+
两个数据源,自动兜底:
|
|
4
|
+
|
|
5
|
+
1. GitHub Releases API(首选,字段完整,匿名限额 60 次/小时)
|
|
6
|
+
2. `releases.atom`(网页订阅源,不限流,但拿不到表情计数等字段)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
from xml.etree import ElementTree
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .models import Release, parse_time
|
|
19
|
+
from .versions import parse_version
|
|
20
|
+
|
|
21
|
+
_USER_AGENT = "nonebot-plugin-github-release/0.1.0"
|
|
22
|
+
_API_ROOT = "https://api.github.com"
|
|
23
|
+
_ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReleaseFetchError(RuntimeError):
|
|
27
|
+
"""拉取失败。"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FetchResult:
|
|
32
|
+
"""一次拉取的结果。"""
|
|
33
|
+
|
|
34
|
+
repo: str
|
|
35
|
+
releases: List[Release]
|
|
36
|
+
source: str # "api" 或 "atom"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _client_kwargs(cfg: Config) -> Dict[str, Any]:
|
|
40
|
+
kwargs: Dict[str, Any] = {
|
|
41
|
+
"timeout": httpx.Timeout(20.0, connect=10.0),
|
|
42
|
+
"follow_redirects": True,
|
|
43
|
+
}
|
|
44
|
+
if cfg.gh_watch_proxy:
|
|
45
|
+
kwargs["proxy"] = cfg.gh_watch_proxy
|
|
46
|
+
return kwargs
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _headers(cfg: Config, accept: str = "application/vnd.github+json") -> Dict[str, str]:
|
|
50
|
+
headers = {
|
|
51
|
+
"Accept": accept,
|
|
52
|
+
"User-Agent": _USER_AGENT,
|
|
53
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
54
|
+
}
|
|
55
|
+
token = (cfg.gh_watch_github_token or "").strip()
|
|
56
|
+
if token:
|
|
57
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
58
|
+
return headers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def fetch_releases(
|
|
62
|
+
cfg: Config, repo: str, *, per_page: int = 30
|
|
63
|
+
) -> FetchResult:
|
|
64
|
+
"""拉取某个仓库最近的 release,API 不行就走 atom。"""
|
|
65
|
+
errors: List[str] = []
|
|
66
|
+
try:
|
|
67
|
+
return FetchResult(repo, await _fetch_from_api(cfg, repo, per_page=per_page), "api")
|
|
68
|
+
except ReleaseFetchError as exc:
|
|
69
|
+
errors.append(f"接口:{exc}")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return FetchResult(repo, await _fetch_from_atom(cfg, repo), "atom")
|
|
73
|
+
except ReleaseFetchError as exc:
|
|
74
|
+
errors.append(f"订阅源:{exc}")
|
|
75
|
+
|
|
76
|
+
raise ReleaseFetchError(";".join(errors))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _fetch_from_api(cfg: Config, repo: str, *, per_page: int = 30) -> List[Release]:
|
|
80
|
+
"""走 Releases API。
|
|
81
|
+
|
|
82
|
+
注意不能用 `/releases/latest`——仓库只发预发布版时它会返回 404。
|
|
83
|
+
"""
|
|
84
|
+
url = f"{_API_ROOT}/repos/{repo}/releases"
|
|
85
|
+
try:
|
|
86
|
+
async with httpx.AsyncClient(**_client_kwargs(cfg)) as client:
|
|
87
|
+
response = await client.get(
|
|
88
|
+
url,
|
|
89
|
+
params={"per_page": max(1, min(per_page, 100))},
|
|
90
|
+
headers=_headers(cfg),
|
|
91
|
+
)
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
payload = response.json()
|
|
94
|
+
except httpx.HTTPStatusError as exc:
|
|
95
|
+
status = exc.response.status_code
|
|
96
|
+
hint = ""
|
|
97
|
+
if status == 404:
|
|
98
|
+
hint = "(仓库不存在或没有 release 权限)"
|
|
99
|
+
elif status in (403, 429):
|
|
100
|
+
hint = "(可能触发了 GitHub 限流,可配置 GH_WATCH_GITHUB_TOKEN)"
|
|
101
|
+
raise ReleaseFetchError(f"HTTP {status}{hint}") from exc
|
|
102
|
+
except httpx.HTTPError as exc:
|
|
103
|
+
raise ReleaseFetchError(f"{exc.__class__.__name__}: {exc}") from exc
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise ReleaseFetchError(f"响应无法解析:{exc}") from exc
|
|
106
|
+
|
|
107
|
+
if not isinstance(payload, list):
|
|
108
|
+
raise ReleaseFetchError("返回了非预期的数据结构")
|
|
109
|
+
|
|
110
|
+
releases: List[Release] = []
|
|
111
|
+
for item in payload:
|
|
112
|
+
if isinstance(item, dict):
|
|
113
|
+
release = Release.from_github(item)
|
|
114
|
+
if release is not None:
|
|
115
|
+
releases.append(release)
|
|
116
|
+
return releases
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _fetch_from_atom(cfg: Config, repo: str) -> List[Release]:
|
|
120
|
+
"""走 releases.atom(不限流)。"""
|
|
121
|
+
url = f"https://github.com/{repo}/releases.atom"
|
|
122
|
+
try:
|
|
123
|
+
async with httpx.AsyncClient(**_client_kwargs(cfg)) as client:
|
|
124
|
+
response = await client.get(
|
|
125
|
+
url, headers=_headers(cfg, "application/atom+xml")
|
|
126
|
+
)
|
|
127
|
+
response.raise_for_status()
|
|
128
|
+
payload = response.text
|
|
129
|
+
except httpx.HTTPStatusError as exc:
|
|
130
|
+
raise ReleaseFetchError(f"HTTP {exc.response.status_code}") from exc
|
|
131
|
+
except httpx.HTTPError as exc:
|
|
132
|
+
raise ReleaseFetchError(f"{exc.__class__.__name__}: {exc}") from exc
|
|
133
|
+
|
|
134
|
+
return parse_atom(payload)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_atom(payload: str) -> List[Release]:
|
|
138
|
+
"""解析 releases.atom 文本。"""
|
|
139
|
+
try:
|
|
140
|
+
root = ElementTree.fromstring(payload)
|
|
141
|
+
except ElementTree.ParseError as exc:
|
|
142
|
+
raise ReleaseFetchError(f"订阅源不是合法 XML:{exc}") from exc
|
|
143
|
+
|
|
144
|
+
releases: List[Release] = []
|
|
145
|
+
for entry in root.findall("atom:entry", _ATOM_NS):
|
|
146
|
+
title = (entry.findtext("atom:title", default="", namespaces=_ATOM_NS) or "").strip()
|
|
147
|
+
link = entry.find("atom:link", _ATOM_NS)
|
|
148
|
+
href = (link.get("href") if link is not None else "") or ""
|
|
149
|
+
updated = entry.findtext("atom:updated", default="", namespaces=_ATOM_NS) or ""
|
|
150
|
+
content = entry.findtext("atom:content", default="", namespaces=_ATOM_NS) or ""
|
|
151
|
+
|
|
152
|
+
# href 形如 https://github.com/<owner>/<repo>/releases/tag/<tag>
|
|
153
|
+
tag = href.rstrip("/").rsplit("/", 1)[-1] if "/tag/" in href else title
|
|
154
|
+
if not tag:
|
|
155
|
+
continue
|
|
156
|
+
version = parse_version(tag)
|
|
157
|
+
releases.append(
|
|
158
|
+
Release(
|
|
159
|
+
tag=tag,
|
|
160
|
+
name=title or tag,
|
|
161
|
+
url=href,
|
|
162
|
+
body=content,
|
|
163
|
+
published_at=parse_time(updated),
|
|
164
|
+
# atom 不含 prerelease 标记,只能从版本号推断
|
|
165
|
+
prerelease=bool(version and not version.is_stable),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if not releases:
|
|
169
|
+
raise ReleaseFetchError("订阅源里没有可用条目")
|
|
170
|
+
return releases
|