|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +If version already exists in changelog.md, exit 0 without modification. |
| 4 | +Usage: python .github/scripts/gen_changelog.py v4.10.9 |
| 5 | +""" |
| 6 | +from __future__ import annotations |
| 7 | +import sys, json, re, html, urllib.request |
| 8 | +from datetime import datetime, timezone, timedelta |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +API_URL = "https://community.fit2cloud.com/v1/products/dataease/releases" |
| 12 | +CHANGELOG = Path('docs/changelog.md') |
| 13 | +MARKER = '## 2 更新内容' |
| 14 | +TZ = timezone(timedelta(hours=8)) |
| 15 | + |
| 16 | +TITLE_MAP = { |
| 17 | + '安全漏洞修复': ('Warning', '**安全漏洞修复**', 'fix'), |
| 18 | + '新增功能': ('Abstract', '新增功能 :star2:', 'feat'), |
| 19 | + '功能优化': ('Abstract', '功能优化 :sunflower:', 'refactor'), |
| 20 | + '问题修复': ('Abstract', '问题修复 :palm_tree:', 'fix'), |
| 21 | + "What's new": ('Abstract', '新增功能 :star2:', 'feat'), |
| 22 | + 'Improvements': ('Abstract', '功能优化 :sunflower:', 'refactor'), |
| 23 | + 'Bug fixes': ('Abstract', '问题修复 :palm_tree:', 'fix'), |
| 24 | +} |
| 25 | +H2_UL = re.compile(r'<h2><a[^>]*></a>([^<]+)</h2>\n?(<ul>.*?</ul>)', re.S) |
| 26 | +# 【新增】用于抓取 h2 后面紧跟的 p 标签 (感谢信息) |
| 27 | +# 逻辑:匹配 <h2>...</h2> 后面可能有的换行,然后匹配 <p>...</p> |
| 28 | +H2_P_THANKS = re.compile(r'<h2><a[^>]*></a>[^<]*</h2>\n?(<p>.*?</p>)', re.S) |
| 29 | +LI = re.compile(r'<li>(.*?)</li>', re.S) |
| 30 | + |
| 31 | + |
| 32 | +def fetch(): |
| 33 | + with urllib.request.urlopen(API_URL, timeout=30) as r: |
| 34 | + return json.loads(r.read().decode()) |
| 35 | + |
| 36 | + |
| 37 | +def normalize(v: str) -> str: |
| 38 | + return v.replace('-lts','') |
| 39 | + |
| 40 | + |
| 41 | +def find_release(target: str, data): |
| 42 | + for rel in data: |
| 43 | + if normalize(rel.get('version','')) == target: |
| 44 | + return rel |
| 45 | + return None |
| 46 | + |
| 47 | + |
| 48 | +def build_block(rel: dict): |
| 49 | + version = normalize(rel['version']) |
| 50 | + ts = rel.get('publishTime') |
| 51 | + dt = datetime.fromtimestamp(ts/1000, tz=TZ) if ts else datetime.now(tz=TZ) |
| 52 | + date_str = f"{dt.year}年{dt.month}月{dt.day}日" |
| 53 | + html_content = rel.get('releaseNoteH', '') |
| 54 | + sections = [] |
| 55 | + |
| 56 | + for title, ul_html in H2_UL.findall(html_content): |
| 57 | + items = LI.findall(ul_html) |
| 58 | + cleaned = [html.unescape(re.sub(r'\s+',' ', re.sub(r'<[^>]+>','', it)).strip()) for it in items if it.strip()] |
| 59 | + thanks_note = "" |
| 60 | + # 简单有效的正则:查找当前 title 对应的 h2 后面的 p |
| 61 | + # 构造动态正则: <h2>...Title...</h2> ... <ul>...</ul> ... <p>(内容)</p> |
| 62 | + # 由于 ul_html 内容可能很长且有特殊字符,直接用 title 定位最安全 |
| 63 | + safe_title = re.escape(title.strip()) |
| 64 | + # 正则解释:匹配包含 title 的 h2,后面任意字符(非贪婪),然后匹配一个 <p>...</p> |
| 65 | + p_pattern = re.compile(r'<h2><a[^>]*></a>' + safe_title + r'</h2>.*?<p>(.*?)</p>', re.S) |
| 66 | + p_match = p_pattern.search(html_content) |
| 67 | + if p_match: |
| 68 | + p_content = p_match.group(1) |
| 69 | + # 清理 HTML 标签,只留文本 |
| 70 | + clean_p = html.unescape(re.sub(r'<[^>]+>', '', p_content)).strip() |
| 71 | + # 只有当内容包含"感谢"或者标题包含"安全"/"漏洞"时,才采纳 |
| 72 | + if "感谢" in clean_p and "漏洞" in title: |
| 73 | + thanks_note = f"\n {clean_p}" |
| 74 | + if not cleaned: |
| 75 | + continue |
| 76 | + admon, nice, tag = TITLE_MAP.get(title, ('info', title, 'note')) |
| 77 | + lines = '\n'.join(f" - {i}" for i in cleaned) |
| 78 | + if thanks_note: |
| 79 | + if lines: |
| 80 | + lines += "\n" + thanks_note |
| 81 | + sections.append(f"!!! {admon} \"{nice}\"\n\n{lines}\n") |
| 82 | + if not sections: |
| 83 | + clean = html.unescape(re.sub(r'<[^>]+>','', html_content)).strip() |
| 84 | + if clean: |
| 85 | + sections.append(f"!!! info \"发布说明\"\n - note: {clean}\n") |
| 86 | + block = '\n'.join([f"### {version}", date_str, ''] + sections) |
| 87 | + return block |
| 88 | + |
| 89 | + |
| 90 | +def main(): |
| 91 | + if len(sys.argv) < 2: |
| 92 | + print('Version arg required, e.g. v2.10.1 or v2.10.1-lts or 2.10.1', file=sys.stderr) |
| 93 | + return 1 |
| 94 | + raw = sys.argv[1].strip() |
| 95 | + # Accept forms: v2.10.1 or v2.10.1-lts or 2.10.1 |
| 96 | + if not raw.startswith('v'): |
| 97 | + raw = 'v' + raw |
| 98 | + target = normalize(raw) # remove -lts suffix if present |
| 99 | + data = fetch() |
| 100 | + rel = find_release(target, data) |
| 101 | + if not rel: |
| 102 | + print(f'Target version {raw} (normalized {target}) not found in API list', file=sys.stderr) |
| 103 | + return 1 |
| 104 | + if not CHANGELOG.exists(): |
| 105 | + print('Changelog file missing.') |
| 106 | + return 1 |
| 107 | + content = CHANGELOG.read_text(encoding='utf-8') |
| 108 | + # 【修复】使用正则严格匹配 Markdown 标题行 (例如: ### v2.10.19) |
| 109 | + # ^ 表示行首,#+ 表示一个或多个#,\s* 表示可选空格,re.escape 防止版本号中的点被当作通配符 |
| 110 | + pattern = re.compile(r'^#+\s*' + re.escape(target) + r'\s*$', re.MULTILINE) |
| 111 | + |
| 112 | + if pattern.search(content): |
| 113 | + print(f'⚠️ Version {target} already exists in changelog.md (Detected by regex). Skip.') |
| 114 | + return 0 |
| 115 | + block = build_block(rel) |
| 116 | + if MARKER in content: |
| 117 | + new_content = content.replace(MARKER, MARKER + '\n\n' + block, 1) |
| 118 | + else: |
| 119 | + new_content = MARKER + '\n\n' + block + content |
| 120 | + CHANGELOG.write_text(new_content, encoding='utf-8') |
| 121 | + print('Inserted changelog for', target) |
| 122 | + return 0 |
| 123 | + |
| 124 | +if __name__ == '__main__': |
| 125 | + raise SystemExit(main()) |
0 commit comments