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.
@@ -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