From f94e7678ece623d9f73d91992c863ee989512745 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 17:22:30 +0800 Subject: [PATCH 01/90] ci: init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Python脚本模块处理 1. Issue 快照链接检查主入口 2. 链接检查模块 3. 链接转换与 Bot 评论生成模块 4. 链接提取模块 - ci流程 --- .github/workflows/issue_content_check.yml | 160 ++++++-------- scripts/python/check_issue.py | 256 ++++++++++++++++++++++ scripts/python/checker.py | 173 +++++++++++++++ scripts/python/converter.py | 173 +++++++++++++++ scripts/python/extractor.py | 104 +++++++++ 5 files changed, 772 insertions(+), 94 deletions(-) create mode 100644 scripts/python/check_issue.py create mode 100644 scripts/python/checker.py create mode 100644 scripts/python/converter.py create mode 100644 scripts/python/extractor.py diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index a2a164774..e9477c929 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -2,113 +2,85 @@ name: issue_content_check on: issues: - types: [opened] + types: [opened, edited] -# 允许修改 Issue(标签、评论、关闭) permissions: issues: write jobs: check-issue-content: runs-on: ubuntu-latest - steps: - - name: 检查 Issue 内容并自动处理 - env: - # GitHub CLI 认证 - GH_TOKEN: ${{ github.token }} - - # 当前仓库(owner/repo) - GH_REPO: ${{ github.repository }} - - # 当前 Issue 信息 - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_BODY: ${{ github.event.issue.body }} - ISSUE_USER: ${{ github.event.issue.user.login }} - - run: | - set -euo pipefail - - # ==================== - # GitHub Issue 操作 - # ==================== - - # 添加 invalid 标签 - add_invalid_label() { - gh issue edit "$ISSUE_NUMBER" \ - --repo "$GH_REPO" \ - --add-label invalid - } - - # 发表评论 - comment_issue() { - gh issue comment "$ISSUE_NUMBER" \ - --repo "$GH_REPO" \ - --body "$1" - } - - # 关闭 Issue - close_issue() { - gh issue close "$ISSUE_NUMBER" \ - --repo "$GH_REPO" - } - - # ==================== - # 内容检查规则 - # ==================== + # ── 环境准备 ── - # 判断是否缺少快照 - # - # 满足任意一种情况即可: - # - GKD 分享链接 - # - ZIP 快照附件 - # - # 两者都不存在时返回 true - missing_snapshot() { - [[ "$ISSUE_BODY" != *"i.gkd.li/"* && - "$ISSUE_BODY" != *".zip"* ]] - } + - name: 检出代码仓库 + uses: actions/checkout@v4 - # 判断是否使用了不可分享的本地快照链接 - # - # 例如: - # https://i.gkd.li/snapshot/xxx - # - # 此类链接仅作者可访问 - unreachable_snapshot_link() { - [[ "$ISSUE_BODY" == *"i.gkd.li/snapshot/"* ]] - } + - name: 配置 Python 运行环境 + uses: actions/setup-python@v5 + with: + python-version: '3.12' - # ==================== - # 自动处理逻辑 - # ==================== + # ── 核心分析(Python 负责)── - # 未提供快照 - # - # 处理方式: - # 1. 标记 invalid - # 2. 自动回复 - # 3. 自动关闭 - if missing_snapshot; then - add_invalid_label + - name: 分析 Issue 快照链接 + id: analyze + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_USER: ${{ github.event.issue.user.login }} + ISSUE_ACTION: ${{ github.action }} + run: python3 scripts/python/check_issue.py - comment_issue \ - "您好 @${ISSUE_USER},由于您没有提供快照,此 issue 已被自动关闭。" + # ── GitHub 操作(Actions / gh CLI 负责)── - gh issue close "${ISSUE_NUMBER}" --reason "not planned" - exit 0 - fi + - name: 添加标签 + if: steps.analyze.outputs.labels_to_add != '' + env: + LABELS: ${{ steps.analyze.outputs.labels_to_add }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + IFS=',' read -ra LABELS <<< "$LABELS" + for label in "${LABELS[@]}"; do + gh issue edit "$ISSUE_NUMBER" --add-label "$label" + done - # 检测到不可访问的快照链接 - # - # 处理方式: - # 1. 标记 invalid - # 2. 自动提醒修正 - # - # 不自动关闭,允许用户补充 - if unreachable_snapshot_link; then - add_invalid_label + - name: 移除旧标签 + if: steps.analyze.outputs.labels_to_remove != '' + env: + LABELS: ${{ steps.analyze.outputs.labels_to_remove }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + IFS=',' read -ra LABELS <<< "$LABELS" + for label in "${LABELS[@]}"; do + gh issue edit "$ISSUE_NUMBER" --remove-label "$label" || true + done + + - name: 发布/更新警告评论 + if: steps.analyze.outputs.warning_comment != '' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + body: ${{ steps.analyze.outputs.warning_comment }} + + - name: 发布/更新快照转换评论 + if: steps.analyze.outputs.bot_comment != '' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + body: ${{ steps.analyze.outputs.bot_comment }} + + - name: 关闭 Issue + if: steps.analyze.outputs.should_close == 'true' + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - comment_issue \ - "您好 @${ISSUE_USER},检测到您提供了他人无法访问的链接,请点击查看 [正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。可在下方评论区补充。" - fi + - name: 重新打开 Issue + if: steps.analyze.outputs.should_reopen == 'true' + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue reopen "$ISSUE_NUMBER" || true diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py new file mode 100644 index 000000000..9cb8f7f83 --- /dev/null +++ b/scripts/python/check_issue.py @@ -0,0 +1,256 @@ +""" +Issue 快照链接检查主入口 + +职责:分析 Issue Body 中的快照链接,将检查结果输出到 GITHUB_OUTPUT。 +不直接操作 GitHub API —— 所有 GitHub 操作由 YAML 工作流完成。 + +流程图: + 接收到 Issue + │ + ▼ + 判断是否缺少快照 ──是──> BAN(关闭 + 标签) + │否 + ▼ + 判断是否使用不可分享本地链接 ──是──> BAN(关闭 + 标签) + │否 + ▼ + 检查不可访问快照链接(i.gkd.li/snapshot/) ──是──> 提醒补充(不关闭) + │ + ▼ + 网络有效性检查(GitHub 附件链接) + │ + ├─ 不可访问 ──> BAN(关闭 + 标签) + ├─ 不确定 ──> 提醒人工核查(不关闭) + │ + ▼ 可访问 + 分类处理: + ├─ GKD 分享链接 ──> pass(无需处理) + └─ GitHub 附件链接 ──> 转换为 GKD 代理链接 + Bot 评论 + +输出变量: + - labels_to_add : 逗号分隔的标签名 + - labels_to_remove : 逗号分隔的标签名(编辑修正后移除旧标签) + - should_close : "true" / "false" + - should_reopen : "true" / "false" + - warning_comment : 警告评论内容(含 标记),为空则不发 + - bot_comment : 快照转换评论内容(含 标记),为空则不发 +""" + +import os + +from extractor import extract_links +from checker import check_local_links, check_unreachable_links, check_network_links +from converter import convert_github_attachments, build_bot_comment + +# ── 本工作流管理的所有标签 ── + +MANAGED_LABELS = [ + "缺失快照(missing-snapshot)", + "本地链接(local-link)", + "需补充链接(need-supplement-link)", + "链接无法访问(inaccessible-link)", +] + +# ── GITHUB_OUTPUT 写入工具 ── + + +def _write_output(key: str, value: str): + """ + 向 GITHUB_OUTPUT 写入一个键值对。 + + 使用 heredoc 语法支持多行值。 + """ + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"{key}< str: + """缺失快照时的警告评论""" + return ( + "\n" + f"您好 @{user},由于您没有提供快照链接,此 Issue 已被自动关闭。\n\n" + "请提供正确的快照链接后重新打开或提交新的 Issue。" + ) + + +def _warning_local_link(user: str) -> str: + """检测到本地链接时的警告评论""" + return ( + "\n" + f"您好 @{user},检测到您使用了不可分享的本地链接" + "(如 localhost、127.0.0.1、file:// 等),他人无法访问该链接," + "此 Issue 已被自动关闭。\n\n" + "请使用正确的分享方式上传快照后重新提交。" + ) + + +def _warning_unreachable_snapshot(user: str) -> str: + """检测到 i.gkd.li/snapshot/ 时的提醒评论(不关闭)""" + return ( + "\n" + f"您好 @{user},检测到您提供了他人无法访问的快照链接" + "(i.gkd.li/snapshot/),请点击查看 " + "[正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。" + "可在下方评论区补充。" + ) + + +def _warning_inaccessible_link(user: str, url: str) -> str: + """链接不可访问(404)时的警告评论""" + return ( + "\n" + f"您好 @{user},检测到您提供的快照链接无法访问:\n\n" + f"`{url}`\n\n" + "此 Issue 已被自动关闭。请确认链接正确后重新提交。" + ) + + +def _warning_uncertain_link(user: str, url: str, status_code: int, detail: str) -> str: + """链接返回不确定状态码时的提醒评论(不关闭)""" + return ( + f"您好 @{user},检测到快照链接访问异常(HTTP {status_code})," + "暂时无法确认链接是否有效,请人工核查:\n\n" + f"`{url}`\n\n" + f"
\n详细错误信息\n\n```\n{detail}\n```\n
" + ) + + +def _recovery_comment(user: str) -> str: + """编辑修正后检查通过时的恢复评论""" + return ( + "\n" + f"✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" + ) + + +# ── 主流程 ── + + +def main(): + body = os.environ.get("ISSUE_BODY", "") or "" + issue_user = os.environ.get("ISSUE_USER", "") + issue_action = os.environ.get("ISSUE_ACTION", "") + + # 默认值 + labels_to_add: list[str] = [] + labels_to_remove: list[str] = [] + should_close = False + should_reopen = False + warning_comment = "" + bot_comment = "" + + # ── 第一步:提取所有链接 ── + links = extract_links(body) + + # ── 第二步:判断是否缺少快照 ── + # 只有在没有任何快照相关链接时才判定为缺失 + has_gkd = any(lnk.kind == "gkd" for lnk in links) + has_github_attachment = any(lnk.kind == "github_attachment" for lnk in links) + has_unreachable = any(lnk.kind == "unreachable_snapshot" for lnk in links) + has_local = any(lnk.kind == "local" for lnk in links) + + if not has_gkd and not has_github_attachment and not has_unreachable and not has_local: + labels_to_add.append("缺失快照(missing-snapshot)") + warning_comment = _warning_missing_snapshot(issue_user) + should_close = True + _output_result( + labels_to_add, labels_to_remove, + should_close, should_reopen, + warning_comment, bot_comment, + ) + return + + # ── 第三步:判断是否使用不可分享本地链接 ── + local_links = check_local_links(links) + if local_links: + labels_to_add.append("本地链接(local-link)") + warning_comment = _warning_local_link(issue_user) + should_close = True + _output_result( + labels_to_add, labels_to_remove, + should_close, should_reopen, + warning_comment, bot_comment, + ) + return + + # ── 第四步:检查不可访问的快照链接(i.gkd.li/snapshot/)── + # 不关闭 Issue,允许用户补充,继续后续检查 + unreachable = check_unreachable_links(links) + if unreachable: + labels_to_add.append("需补充链接(need-supplement-link)") + warning_comment = _warning_unreachable_snapshot(issue_user) + + # ── 第五步:网络有效性检查(仅针对 GitHub 附件链接)── + attachment_links = [lnk for lnk in links if lnk.kind == "github_attachment"] + banned = False + for lnk in attachment_links: + result = check_network_links(lnk.url) + if result.status == "404": + labels_to_add.append("链接无法访问(inaccessible-link)") + warning_comment = _warning_inaccessible_link(issue_user, lnk.url) + should_close = True + banned = True + break + elif result.status == "uncertain": + labels_to_add.append("链接无法访问(inaccessible-link)") + uncertain_msg = _warning_uncertain_link( + issue_user, lnk.url, result.status_code, result.detail, + ) + # 追加到已有警告评论,或新建 + if warning_comment: + warning_comment += f"\n\n---\n\n{uncertain_msg}" + else: + warning_comment = "\n" + uncertain_msg + + if banned: + _output_result( + labels_to_add, labels_to_remove, + should_close, should_reopen, + warning_comment, bot_comment, + ) + return + + # ── 第六步:分类处理 ── + # GKD 分享链接 → pass(无需处理) + # GitHub 附件链接 → 转换为 GKD 代理链接 + Bot 评论 + if attachment_links: + converted = convert_github_attachments(attachment_links) + if converted: + comment_body = build_bot_comment(converted) + bot_comment = "\n" + comment_body + + # ── 第七步:编辑时恢复 Issue 状态 ── + if issue_action == "edited" and not labels_to_add and not should_close: + should_reopen = True + labels_to_remove = list(MANAGED_LABELS) + warning_comment = _recovery_comment(issue_user) + + _output_result( + labels_to_add, labels_to_remove, + should_close, should_reopen, + warning_comment, bot_comment, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/python/checker.py b/scripts/python/checker.py new file mode 100644 index 000000000..c2a174c06 --- /dev/null +++ b/scripts/python/checker.py @@ -0,0 +1,173 @@ +""" +链接检查模块 + +负责三类检查: +1. 本地链接检查:识别 localhost / 127.0.0.1 / file:// 等不可分享链接 +2. 不可访问快照链接检查:识别 i.gkd.li/snapshot/ 链接 +3. 网络有效性检查:对 GitHub 附件链接发起 HTTP 请求,验证可访问性 +""" + +from dataclasses import dataclass + +from extractor import LinkInfo + + +# ── 数据结构 ── + + +@dataclass +class NetworkResult: + """网络请求检查结果""" + + status: str # "ok" / "404" / "uncertain" + status_code: int = 0 + detail: str = "" + + +# ── 本地链接检查 ── + + +def check_local_links(links: list[LinkInfo]) -> list[LinkInfo]: + """ + 筛选出所有本地不可分享链接。 + + 返回值为空列表时表示没有本地链接,可继续后续检查。 + """ + return [lnk for lnk in links if lnk.kind == "local"] + + +# ── 不可访问快照链接检查 ── + + +def check_unreachable_links(links: list[LinkInfo]) -> list[LinkInfo]: + """ + 筛选出所有 i.gkd.li/snapshot/ 类型的不可访问链接。 + + 此类链接仅作者可访问,他人无法打开。 + """ + return [lnk for lnk in links if lnk.kind == "unreachable_snapshot"] + + +# ── 网络有效性检查 ── + + +def check_network_links(url: str, timeout: int = 20) -> NetworkResult: + """ + 对单个 URL 发起网络请求,验证其可访问性。 + + 请求策略(按优先级): + 1. HEAD 请求 —— 最快,只获取响应头 + 2. GET 请求 + Range 头 —— 只请求前 1 字节,兼容不支持 HEAD 的服务器 + + 返回值: + - status="ok":链接可正常访问 + - status="404":链接返回 404,确认不可访问 + - status="uncertain":返回 403/5xx 等不确定状态码 + """ + import urllib.request + import urllib.error + + # 尝试 HEAD 请求 + result = _try_head_request(url, timeout) + if result is not None: + return result + + # HEAD 不被支持,回退到 GET + Range + return _try_get_range_request(url, timeout) + + +def _try_head_request(url: str, timeout: int) -> NetworkResult | None: + """ + 发起 HEAD 请求。 + + 返回 None 表示服务器不支持 HEAD(如返回 405), + 需要回退到 GET 请求。 + """ + import urllib.request + import urllib.error + + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "GKD-Issue-Checker/1.0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return NetworkResult(status="ok", status_code=resp.status) + except urllib.error.HTTPError as e: + if e.code == 404: + return NetworkResult(status="404", status_code=404) + if e.code == 405: + # 服务器不支持 HEAD 方法,回退 + return None + if e.code == 403: + return NetworkResult( + status="uncertain", + status_code=403, + detail=f"HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + ) + if 500 <= e.code < 600: + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", + ) + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} {e.reason}", + ) + except Exception as e: + return NetworkResult( + status="uncertain", + status_code=0, + detail=f"请求异常: {type(e).__name__}: {e}", + ) + + +def _try_get_range_request(url: str, timeout: int) -> NetworkResult: + """ + 发起 GET 请求 + Range 头(只请求前 1 字节)。 + + 用于兼容不支持 HEAD 方法的服务器。 + """ + import urllib.request + import urllib.error + + try: + req = urllib.request.Request(url, method="GET") + req.add_header("User-Agent", "GKD-Issue-Checker/1.0") + req.add_header("Range", "bytes=0-0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + code = resp.status + # 206 = Partial Content(正常),200 = 服务器忽略了 Range 头也算正常 + if code in (200, 206): + return NetworkResult(status="ok", status_code=code) + return NetworkResult( + status="uncertain", + status_code=code, + detail=f"GET 请求返回非预期状态码: {code}", + ) + except urllib.error.HTTPError as e: + if e.code == 404: + return NetworkResult(status="404", status_code=404) + if e.code == 403: + return NetworkResult( + status="uncertain", + status_code=403, + detail=f"HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + ) + if 500 <= e.code < 600: + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", + ) + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} {e.reason}", + ) + except Exception as e: + return NetworkResult( + status="uncertain", + status_code=0, + detail=f"请求异常: {type(e).__name__}: {e}", + ) \ No newline at end of file diff --git a/scripts/python/converter.py b/scripts/python/converter.py new file mode 100644 index 000000000..6905904f0 --- /dev/null +++ b/scripts/python/converter.py @@ -0,0 +1,173 @@ +""" +链接转换与 Bot 评论生成模块 + +负责: +1. 将 GitHub 附件链接转换为 GKD 代理链接 +2. 按文件名中的 App/Activity 分组 +3. 生成格式化的 Bot 评论内容 +""" + +import re +from dataclasses import dataclass + +from extractor import LinkInfo + + +# ── 数据结构 ── + + +@dataclass +class ConvertedLink: + """转换后的链接信息""" + + original_url: str # 原始 GitHub 附件 URL + converted_url: str # 转换后的 GKD 代理 URL + display_text: str # 原始 Markdown 链接的显示文字 + app_name: str # 从文件名提取的 App 名称 + activity_name: str # 从文件名提取的 Activity 名称 + timestamp: str # 从文件名提取的时间戳 + + +# ── 常量 ── + +# GKD 代理链接模板 +GKD_PROXY_TEMPLATE = "https://i.gkd.li/i?url={url}" + +# 文件名模式:{App}_{Activity}-{timestamp}.zip +# 例如:QQ_SplashActivity-1781663723542.zip +_RE_FILENAME = re.compile( + r"https://github\.com/user-attachments/files/\d+/(.+)" +) + +_RE_NAME_PATTERN = re.compile( + r"^(?P.+?)_(?P.+?)-(?P\d+)\.zip$" +) + + +# ── 转换逻辑 ── + + +def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: + """ + 将 GitHub 附件链接转换为 GKD 代理链接。 + + 仅处理 kind == "github_attachment" 的链接。 + 文件名不符合 {App}_{Activity}-{timestamp}.zip 模式的, + app_name / activity_name / timestamp 设为空字符串。 + """ + results: list[ConvertedLink] = [] + for lnk in links: + if lnk.kind != "github_attachment": + continue + + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + + # 从 URL 中提取文件名 + filename_match = _RE_FILENAME.match(lnk.url) + filename = filename_match.group(1) if filename_match else "" + + # 尝试解析文件名中的 App / Activity / timestamp + app_name = "" + activity_name = "" + timestamp = "" + if filename: + name_match = _RE_NAME_PATTERN.match(filename) + if name_match: + app_name = name_match.group("app") + activity_name = name_match.group("activity") + timestamp = name_match.group("timestamp") + + results.append( + ConvertedLink( + original_url=lnk.url, + converted_url=converted_url, + display_text=lnk.display_text, + app_name=app_name, + activity_name=activity_name, + timestamp=timestamp, + ) + ) + + return results + + +# ── 评论生成 ── + + +def build_bot_comment(converted: list[ConvertedLink]) -> str: + """ + 生成 Bot 评论内容。 + + 格式: + ### AppName + #### ActivityName + [timestamp](转换后URL) ← 有 display_text 时用 [display_text](转换后URL) + + 底部
折叠所有原始附件 URL。 + """ + if not converted: + return "" + + # 按文件名模式分组:app → activity → [links] + grouped: dict[str, dict[str, list[ConvertedLink]]] = {} + ungrouped: list[ConvertedLink] = [] + + for item in converted: + if item.app_name and item.activity_name: + grouped.setdefault(item.app_name, {}).setdefault( + item.activity_name, [] + ).append(item) + else: + ungrouped.append(item) + + lines: list[str] = [] + + # 生成分组部分 + for app_name in _stable_key_order(grouped): + activities = grouped[app_name] + lines.append(f"### {app_name}") + for activity_name in _stable_key_order(activities): + items = activities[activity_name] + lines.append(f"#### {activity_name}") + for item in items: + lines.append(_format_link_line(item)) + lines.append("") + + # 生成未分组部分(文件名不匹配模式) + if ungrouped: + for item in ungrouped: + lines.append(_format_link_line(item)) + lines.append("") + + # 生成快速复制折叠区 + lines.append("
") + lines.append("快速复制") + lines.append("") + lines.append("## 快速复制") + lines.append("```") + for item in converted: + lines.append(item.original_url) + lines.append("```") + lines.append("
") + + return "\n".join(lines) + + +def _format_link_line(item: ConvertedLink) -> str: + """ + 格式化单条链接行。 + + - 有 display_text 时:[display_text](converted_url) + - 有 timestamp 时:[timestamp](converted_url) + - 否则:直接输出 converted_url + """ + if item.display_text: + return f"[{item.display_text}]({item.converted_url})" + if item.timestamp: + return f"[{item.timestamp}]({item.converted_url})" + return item.converted_url + + +def _stable_key_order(d: dict) -> list[str]: + """按插入顺序返回字典的键(Python 3.7+ dict 保持插入顺序)。""" + return list(d.keys()) \ No newline at end of file diff --git a/scripts/python/extractor.py b/scripts/python/extractor.py new file mode 100644 index 000000000..7cee88cb2 --- /dev/null +++ b/scripts/python/extractor.py @@ -0,0 +1,104 @@ +""" +链接提取模块 + +从 Issue Body 中提取所有快照相关链接,并分类为: +- gkd:GKD 分享链接 (https://i.gkd.li/i/XXXXXXXX) +- github_attachment:GitHub 附件链接 (github.com/user-attachments/files/) +- local:不可分享的本地链接 (localhost / 127.0.0.1 / file://) +- unreachable_snapshot:不可访问的快照链接 (i.gkd.li/snapshot/) +""" + +import re +from dataclasses import dataclass + + +@dataclass +class LinkInfo: + """提取出的单条链接信息""" + + url: str + kind: str # gkd / github_attachment / local / unreachable_snapshot + display_text: str # Markdown 链接的显示文字,纯文本时为空 + + +# ── 正则模式 ── + +# Markdown 格式链接:[显示文字](URL) +_RE_MD_LINK = re.compile(r"\[([^\]]*)\]\(([^)]+)\)") + +# GKD 分享链接:https://i.gkd.li/i/数字 +_RE_GKD_LINK = re.compile(r"https://i\.gkd\.li/i/\d+") + +# GitHub 附件链接:https://github.com/user-attachments/files/... +_RE_GITHUB_ATTACHMENT = re.compile( + r"https://github\.com/user-attachments/files/[^\s\)]+" +) + +# 不可访问的快照链接:https://i.gkd.li/snapshot/... +_RE_UNREACHABLE_SNAPSHOT = re.compile(r"https://i\.gkd\.li/snapshot/[^\s\)]*") + +# 本地链接:localhost / 127.0.0.1 / file:// 等 +_RE_LOCAL_LINK = re.compile( + r"(?:https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s\)]*" + r"|file://[^\s\)]*)", + re.IGNORECASE, +) + + +def _classify_url(url: str) -> str | None: + """ + 对单个 URL 进行分类。 + + 返回值: + - "gkd":GKD 分享链接 + - "github_attachment":GitHub 附件链接 + - "local":本地不可分享链接 + - "unreachable_snapshot":不可访问的快照链接 + - None:不属于以上任何类别 + """ + if _RE_LOCAL_LINK.match(url): + return "local" + if _RE_UNREACHABLE_SNAPSHOT.match(url): + return "unreachable_snapshot" + if _RE_GKD_LINK.match(url): + return "gkd" + if _RE_GITHUB_ATTACHMENT.match(url): + return "github_attachment" + return None + + +def extract_links(body: str) -> list[LinkInfo]: + """ + 从 Issue Body 中提取所有快照相关链接。 + + 处理两种格式: + 1. Markdown 链接:[文字](URL) → 保留显示文字 + 2. 纯文本 URL:直接匹配 → display_text 为空 + """ + seen: set[str] = set() + results: list[LinkInfo] = [] + + # 先提取 Markdown 格式链接 + for match in _RE_MD_LINK.finditer(body): + display_text = match.group(1) + url = match.group(2) + kind = _classify_url(url) + if kind and url not in seen: + seen.add(url) + results.append(LinkInfo(url=url, kind=kind, display_text=display_text)) + + # 再提取纯文本 URL(排除已被 Markdown 链接捕获的) + all_url_patterns = [ + (_RE_LOCAL_LINK, "local"), + (_RE_UNREACHABLE_SNAPSHOT, "unreachable_snapshot"), + (_RE_GKD_LINK, "gkd"), + (_RE_GITHUB_ATTACHMENT, "github_attachment"), + ] + for pattern, kind in all_url_patterns: + for match in pattern.finditer(body): + url = match.group(0) + if url not in seen: + seen.add(url) + results.append(LinkInfo(url=url, kind=kind, display_text="")) + + return results \ No newline at end of file From a0f09a38b97f1d693310a6783b2c88cb0ab9c84e Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 17:33:36 +0800 Subject: [PATCH 02/90] fix(ci): GH_TOKEN path --- .github/workflows/issue_content_check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index e9477c929..c6012929e 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -38,6 +38,7 @@ jobs: env: LABELS: ${{ steps.analyze.outputs.labels_to_add }} ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | IFS=',' read -ra LABELS <<< "$LABELS" for label in "${LABELS[@]}"; do @@ -49,6 +50,7 @@ jobs: env: LABELS: ${{ steps.analyze.outputs.labels_to_remove }} ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | IFS=',' read -ra LABELS <<< "$LABELS" for label in "${LABELS[@]}"; do @@ -77,10 +79,12 @@ jobs: if: steps.analyze.outputs.should_close == 'true' env: ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - name: 重新打开 Issue if: steps.analyze.outputs.should_reopen == 'true' env: ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh issue reopen "$ISSUE_NUMBER" || true From 0059d7f3a625218343a111b19719502249c51a0c Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 18:47:10 +0800 Subject: [PATCH 03/90] =?UTF-8?q?chore:=20CI=E6=9E=B6=E6=9E=84=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/project_rules.md | 234 +++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 .trae/rules/project_rules.md diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md new file mode 100644 index 000000000..3fed0086f --- /dev/null +++ b/.trae/rules/project_rules.md @@ -0,0 +1,234 @@ +# GKD_subscription 项目规则 + +## 项目概述 + +GKD 订阅项目,基于 Node.js + TypeScript 编写 Android 应用自动化规则。 +Issue 内容检查工作流用于自动审核用户提交的快照链接。 + +--- + +## Issue 内容检查工作流 — 架构规则 + +### 核心架构:Orchestrator + Worker + +``` +GitHub Actions (.yml) = Orchestrator(编排器) +Python (scripts/python/) = Worker(分析器) +``` + +两者职责**严格分离**。 + +### GitHub Actions (.yml) 职责 + +- Workflow 触发与权限声明 +- Job / Step 编排与条件分支(if) +- 环境准备(checkout、setup-python) +- 标签操作(gh CLI) +- 评论操作(peter-evans/create-or-update-comment@v5) +- Issue 关闭 / 重新打开(gh CLI) +- 读取 Python 输出,决定执行哪些 Step + +**原则:GitHub Actions 能完成的事,不允许放进 Python。** + +### Python 职责 + +- Markdown 文本解析与正则匹配 +- URL 提取与分类 +- HTTP 网络请求(HEAD / GET+Range) +- 数据转换(GitHub 附件 → GKD 代理链接) +- Markdown 评论内容生成 +- 结果输出到 GITHUB_OUTPUT + +**Python 禁止:** +- 调用 GitHub REST API +- 打标签 / 移除标签 +- 发表 / 更新评论 +- 关闭 / 打开 Issue +- 任何 GitHub 状态修改 + +--- + +## 工作流业务流程 + +``` +接收到 Issue (opened / edited) + │ + ▼ +环境准备 (checkout + setup-python) + │ + ▼ +Python 分析 Issue Body(只运行一次) + 输出原子化标志到 GITHUB_OUTPUT + │ + ▼ +是否缺少快照? ──是──> BAN(标签 + 评论 + 关闭) + │否 + ▼ +是否本地链接? ──是──> BAN(标签 + 评论 + 关闭) + │否 + ▼ +是否有不可访问快照(i.gkd.li/snapshot/)? ──是──> 提醒补充(标签 + 评论,不关闭) + │ + ▼ +网络检查 GitHub 附件链接 + │ + ├─ 404 ──> BAN(标签 + 评论 + 关闭) + ├─ 不确定(403/5xx) ──> 提醒人工核查(标签 + 折叠错误详情,不关闭) + │ + ▼ 可访问 +链接转换 + Bot 评论生成 + │ + ▼ +发布/更新 Bot 评论 (create-or-update-comment) + │ + ▼ +编辑恢复处理(edited 触发时:移除旧标签 + 重新打开 + 恢复评论) +``` + +--- + +## 关键设计决策 + +### 1. Python 只运行一次 + +Python 脚本只执行一次,输出所有原子化布尔标志。 +YAML 根据这些标志决定执行哪些 Step。 + +**原因:** 减少 setup 开销,避免重复解析 Issue Body。 + +### 2. Fail Fast 原则 + +网络检查遇到第一个致命错误(404)立即停止,不发后续请求。 + +**原因:** 节省网络请求和运行时间,审核类工作流不需要完整报告。 + +### 3. 幂等性(Idempotent) + +每次 opened 或 edited 触发都完全重跑全流程,保证最终状态一致。 + +**原因:** 避免遗留旧标签或旧评论,行为可预测。 + +### 4. 评论防刷屏 + +使用 `peter-evans/find-comment@v4` 查找已有评论 ID,再用 `peter-evans/create-or-update-comment@v5` + `comment-id` + `edit-mode: replace` 更新,而非重复创建。 + +--- + +## Python 输出变量 + +| 变量名 | 类型 | 含义 | +| ----------------- | ------ | ------------------------------------------------------------------------------------------------ | +| `has_snapshot` | bool | 是否包含任何快照链接 | +| `has_local_link` | bool | 是否包含本地链接 | +| `has_unreachable` | bool | 是否包含不可访问快照 | +| `network_status` | string | 网络检查结果:`ok` / `404` / `uncertain` / `skipped` | +| `network_detail` | string | 网络错误详情(折叠展示用) | +| `has_convertible` | bool | 是否有可转换的 GitHub 附件 | +| `warning_type` | string | 警告类型:`missing` / `local` / `unreachable` / `inaccessible` / `uncertain` / `recovery` / `""` | +| `warning_comment` | string | 警告评论 Markdown(含 `` 标记) | +| `bot_comment` | string | Bot 评论 Markdown(含 `` 标记) | + +--- + +## 标签定义 + +| 场景 | 标签名 | 是否关闭 Issue | +| ------------------------- | ---------------------------------- | ---------------------- | +| 缺失快照 | `缺失快照(missing-snapshot)` | ✅ 关闭(not planned) | +| 本地链接 | `本地链接(local-link)` | ✅ 关闭(not planned) | +| 不可访问快照链接 | `需补充链接(need-supplement-link)` | ❌ 不关闭 | +| 链接无法访问(404/403/5xx) | `链接无法访问(inaccessible-link)` | 404关闭,403/5xx不关闭 | + +--- + +## 链接识别规则 + +| 类型 | 匹配模式 | 分类 | +| ------------ | ----------------------------------------------- | ---------------------- | +| GKD 分享链接 | `https://i.gkd.li/i/\d+` | `gkd` | +| GitHub 附件 | `https://github.com/user-attachments/files/...` | `github_attachment` | +| 本地链接 | `localhost` / `127.0.0.1` / `file://` | `local` | +| 不可访问快照 | `https://i.gkd.li/snapshot/...` | `unreachable_snapshot` | + +--- + +## 链接转换规则 + +- 仅转换 `github_attachment` 类型链接 +- 转换公式:`https://i.gkd.li/i?url={{原始GitHub附件URL}}` +- GKD 链接原样保留,不转换 + +--- + +## Bot 评论格式 + +### 分组规则 + +从附件文件名中提取 `{App}_{Activity}-{timestamp}.zip` 模式: +- `### AppName`(一级标题) +- `#### ActivityName`(二级标题) +- `[timestamp](转换后URL)` 或 `[display_text](转换后URL)` + +### 不匹配文件名模式 + +文件名不符合 `{App}_{Activity}-{timestamp}.zip` 的附件,不分组,逐条列出。 + +### 快速复制折叠区 + +评论底部包含 `
` 折叠区,列出所有原始附件 URL。 + +--- + +## Python 模块结构 + +``` +scripts/python/ + ├── check_issue.py # 主入口:协调各模块,输出分析结果 + ├── extractor.py # 链接提取与分类 + ├── checker.py # 三类检查(本地/不可访问/网络) + ├── converter.py # GitHub 附件 → GKD 代理链接转换 + ├── formatter.py # Bot 评论 Markdown 格式化生成 + └── utils.py # 公共工具函数(GITHUB_OUTPUT 写入等) +``` + +### 模块化要求 + +- 每个文件职责单一 +- 禁止互相重复代码 +- 禁止一个几百行的大脚本 +- 每个文件顶部说明用途 +- 每个函数必须有注释 +- 复杂逻辑必须有注释 + +--- + +## 网络检查策略 + +1. 优先 HEAD 请求(最快,只获取响应头) +2. HEAD 返回 405 时回退到 GET + Range 头(只请求前 1 字节) +3. 超时时间:20 秒 +4. 404 → 确认不可访问 +5. 403 / 5xx → 不确定,折叠展示错误详情 +6. 3xx → 跟随重定向,以最终状态码为准 + +--- + +## 使用的 GitHub Actions + +| Action | 用途 | +| ----------------------------------------- | --------------------------- | +| `actions/checkout@v4` | 拉取仓库代码 | +| `actions/setup-python@v5` | 初始化 Python 环境 | +| `peter-evans/find-comment@v4` | 查找已有评论(按作者+内容) | +| `peter-evans/create-or-update-comment@v5` | 发布/更新评论(防刷屏) | +| `gh` CLI(内置) | 标签操作、关闭/打开 Issue | + +--- + +## 代码风格 + +- Python 文件使用 UTF-8 编码 +- 类型注解(Python 3.10+ 语法) +- dataclass 用于数据结构 +- 不使用第三方库,仅使用 Python 标准库 +- YAML Step 名称使用中文 \ No newline at end of file From 72da240b17c4398c3d997cc2bce3a316d4087ff0 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 18:53:11 +0800 Subject: [PATCH 04/90] =?UTF-8?q?refactor:=20Python=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/check_issue.py | 380 ++++++++++++++++------------------ scripts/python/checker.py | 16 +- scripts/python/converter.py | 119 ++--------- scripts/python/extractor.py | 23 +- scripts/python/formatter.py | 156 ++++++++++++++ scripts/python/utils.py | 28 +++ 6 files changed, 407 insertions(+), 315 deletions(-) create mode 100644 scripts/python/formatter.py create mode 100644 scripts/python/utils.py diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py index 9cb8f7f83..7e5915f9a 100644 --- a/scripts/python/check_issue.py +++ b/scripts/python/check_issue.py @@ -1,146 +1,49 @@ """ Issue 快照链接检查主入口 -职责:分析 Issue Body 中的快照链接,将检查结果输出到 GITHUB_OUTPUT。 +职责:协调各模块执行分析流程,将原子化结果输出到 GITHUB_OUTPUT。 不直接操作 GitHub API —— 所有 GitHub 操作由 YAML 工作流完成。 -流程图: - 接收到 Issue - │ - ▼ - 判断是否缺少快照 ──是──> BAN(关闭 + 标签) - │否 - ▼ - 判断是否使用不可分享本地链接 ──是──> BAN(关闭 + 标签) - │否 - ▼ - 检查不可访问快照链接(i.gkd.li/snapshot/) ──是──> 提醒补充(不关闭) - │ - ▼ - 网络有效性检查(GitHub 附件链接) - │ - ├─ 不可访问 ──> BAN(关闭 + 标签) - ├─ 不确定 ──> 提醒人工核查(不关闭) - │ - ▼ 可访问 - 分类处理: - ├─ GKD 分享链接 ──> pass(无需处理) - └─ GitHub 附件链接 ──> 转换为 GKD 代理链接 + Bot 评论 - -输出变量: - - labels_to_add : 逗号分隔的标签名 - - labels_to_remove : 逗号分隔的标签名(编辑修正后移除旧标签) - - should_close : "true" / "false" - - should_reopen : "true" / "false" - - warning_comment : 警告评论内容(含 标记),为空则不发 - - bot_comment : 快照转换评论内容(含 标记),为空则不发 +流程(Fail Fast,遇到致命错误立即停止): + 1. 提取链接 → 判断是否缺少快照(致命) + 2. 检查本地链接(致命) + 3. 检查不可访问快照链接(非致命,继续) + 4. 网络有效性检查(404 致命 / 不确定非致命) + 5. 链接转换 + Bot 评论生成 + 6. 编辑恢复判断 + +输出变量(原子化标志,供 YAML 工作流条件判断): + - has_snapshot : 是否包含任何快照链接 + - has_local_link : 是否包含本地链接 + - has_unreachable : 是否包含不可访问快照 + - network_status : 网络检查结果 (ok / 404 / uncertain / skipped) + - network_detail : 网络错误详情 + - has_convertible : 是否有可转换的 GitHub 附件 + - warning_type : 警告类型 (missing / local / unreachable / inaccessible / uncertain / recovery / "") + - warning_comment : 警告评论 Markdown(含 标记) + - bot_comment : Bot 评论 Markdown(含 标记) """ import os from extractor import extract_links from checker import check_local_links, check_unreachable_links, check_network_links -from converter import convert_github_attachments, build_bot_comment +from converter import convert_github_attachments +from formatter import ( + build_warning_missing, + build_warning_local, + build_warning_unreachable, + build_warning_inaccessible, + build_warning_uncertain, + build_recovery_comment, + build_bot_comment, +) +from utils import write_output -# ── 本工作流管理的所有标签 ── -MANAGED_LABELS = [ - "缺失快照(missing-snapshot)", - "本地链接(local-link)", - "需补充链接(need-supplement-link)", - "链接无法访问(inaccessible-link)", -] +# ── 快照相关链接类型集合 ── -# ── GITHUB_OUTPUT 写入工具 ── - - -def _write_output(key: str, value: str): - """ - 向 GITHUB_OUTPUT 写入一个键值对。 - - 使用 heredoc 语法支持多行值。 - """ - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: - f.write(f"{key}< str: - """缺失快照时的警告评论""" - return ( - "\n" - f"您好 @{user},由于您没有提供快照链接,此 Issue 已被自动关闭。\n\n" - "请提供正确的快照链接后重新打开或提交新的 Issue。" - ) - - -def _warning_local_link(user: str) -> str: - """检测到本地链接时的警告评论""" - return ( - "\n" - f"您好 @{user},检测到您使用了不可分享的本地链接" - "(如 localhost、127.0.0.1、file:// 等),他人无法访问该链接," - "此 Issue 已被自动关闭。\n\n" - "请使用正确的分享方式上传快照后重新提交。" - ) - - -def _warning_unreachable_snapshot(user: str) -> str: - """检测到 i.gkd.li/snapshot/ 时的提醒评论(不关闭)""" - return ( - "\n" - f"您好 @{user},检测到您提供了他人无法访问的快照链接" - "(i.gkd.li/snapshot/),请点击查看 " - "[正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。" - "可在下方评论区补充。" - ) - - -def _warning_inaccessible_link(user: str, url: str) -> str: - """链接不可访问(404)时的警告评论""" - return ( - "\n" - f"您好 @{user},检测到您提供的快照链接无法访问:\n\n" - f"`{url}`\n\n" - "此 Issue 已被自动关闭。请确认链接正确后重新提交。" - ) - - -def _warning_uncertain_link(user: str, url: str, status_code: int, detail: str) -> str: - """链接返回不确定状态码时的提醒评论(不关闭)""" - return ( - f"您好 @{user},检测到快照链接访问异常(HTTP {status_code})," - "暂时无法确认链接是否有效,请人工核查:\n\n" - f"`{url}`\n\n" - f"
\n详细错误信息\n\n```\n{detail}\n```\n
" - ) - - -def _recovery_comment(user: str) -> str: - """编辑修正后检查通过时的恢复评论""" - return ( - "\n" - f"✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" - ) +_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot", "local"} # ── 主流程 ── @@ -151,106 +54,179 @@ def main(): issue_user = os.environ.get("ISSUE_USER", "") issue_action = os.environ.get("ISSUE_ACTION", "") - # 默认值 - labels_to_add: list[str] = [] - labels_to_remove: list[str] = [] - should_close = False - should_reopen = False + # 初始化所有输出变量 + has_snapshot = "true" + has_local_link = "false" + has_unreachable = "false" + network_status = "skipped" + network_detail = "" + has_convertible = "false" + warning_type = "" warning_comment = "" bot_comment = "" # ── 第一步:提取所有链接 ── links = extract_links(body) - # ── 第二步:判断是否缺少快照 ── - # 只有在没有任何快照相关链接时才判定为缺失 - has_gkd = any(lnk.kind == "gkd" for lnk in links) - has_github_attachment = any(lnk.kind == "github_attachment" for lnk in links) - has_unreachable = any(lnk.kind == "unreachable_snapshot" for lnk in links) - has_local = any(lnk.kind == "local" for lnk in links) - - if not has_gkd and not has_github_attachment and not has_unreachable and not has_local: - labels_to_add.append("缺失快照(missing-snapshot)") - warning_comment = _warning_missing_snapshot(issue_user) - should_close = True - _output_result( - labels_to_add, labels_to_remove, - should_close, should_reopen, - warning_comment, bot_comment, + # ── 第二步:判断是否缺少快照(致命 → 提前返回) ── + has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) + + if not has_any_snapshot: + _output( + has_snapshot="false", + has_local_link="false", + has_unreachable="false", + network_status="skipped", + network_detail="", + has_convertible="false", + warning_type="missing", + warning_comment=build_warning_missing(issue_user), + bot_comment="", ) return - # ── 第三步:判断是否使用不可分享本地链接 ── + # ── 第三步:检查本地链接(致命 → 提前返回) ── local_links = check_local_links(links) + if local_links: - labels_to_add.append("本地链接(local-link)") - warning_comment = _warning_local_link(issue_user) - should_close = True - _output_result( - labels_to_add, labels_to_remove, - should_close, should_reopen, - warning_comment, bot_comment, + _output( + has_snapshot="true", + has_local_link="true", + has_unreachable="false", + network_status="skipped", + network_detail="", + has_convertible="false", + warning_type="local", + warning_comment=build_warning_local(issue_user), + bot_comment="", ) return - # ── 第四步:检查不可访问的快照链接(i.gkd.li/snapshot/)── - # 不关闭 Issue,允许用户补充,继续后续检查 - unreachable = check_unreachable_links(links) - if unreachable: - labels_to_add.append("需补充链接(need-supplement-link)") - warning_comment = _warning_unreachable_snapshot(issue_user) + # ── 第四步:检查不可访问快照链接(非致命,继续后续检查) ── + unreachable_links = check_unreachable_links(links) + has_unreachable = "true" if unreachable_links else "false" + + if unreachable_links: + warning_type = "unreachable" + warning_comment = build_warning_unreachable(issue_user) - # ── 第五步:网络有效性检查(仅针对 GitHub 附件链接)── + # ── 第五步:网络有效性检查(仅 GitHub 附件) ── attachment_links = [lnk for lnk in links if lnk.kind == "github_attachment"] - banned = False - for lnk in attachment_links: - result = check_network_links(lnk.url) - if result.status == "404": - labels_to_add.append("链接无法访问(inaccessible-link)") - warning_comment = _warning_inaccessible_link(issue_user, lnk.url) - should_close = True - banned = True - break - elif result.status == "uncertain": - labels_to_add.append("链接无法访问(inaccessible-link)") - uncertain_msg = _warning_uncertain_link( - issue_user, lnk.url, result.status_code, result.detail, + + if attachment_links: + network_status, network_detail, warning_type, warning_comment = ( + _check_attachments( + attachment_links, issue_user, has_unreachable, warning_type, warning_comment ) - # 追加到已有警告评论,或新建 - if warning_comment: - warning_comment += f"\n\n---\n\n{uncertain_msg}" - else: - warning_comment = "\n" + uncertain_msg - - if banned: - _output_result( - labels_to_add, labels_to_remove, - should_close, should_reopen, - warning_comment, bot_comment, ) - return - # ── 第六步:分类处理 ── - # GKD 分享链接 → pass(无需处理) - # GitHub 附件链接 → 转换为 GKD 代理链接 + Bot 评论 - if attachment_links: + # 404 是致命错误 → 提前返回 + if network_status == "404": + _output( + has_snapshot=has_snapshot, + has_local_link=has_local_link, + has_unreachable=has_unreachable, + network_status=network_status, + network_detail=network_detail, + has_convertible="false", + warning_type=warning_type, + warning_comment=warning_comment, + bot_comment="", + ) + return + + # ── 第六步:链接转换 + Bot 评论生成(仅当网络检查通过时) ── + if network_status in ("ok", "skipped") and attachment_links: converted = convert_github_attachments(attachment_links) + has_convertible = "true" if converted else "false" + if converted: comment_body = build_bot_comment(converted) bot_comment = "\n" + comment_body - # ── 第七步:编辑时恢复 Issue 状态 ── - if issue_action == "edited" and not labels_to_add and not should_close: - should_reopen = True - labels_to_remove = list(MANAGED_LABELS) - warning_comment = _recovery_comment(issue_user) + # ── 第七步:编辑恢复判断 ── + # 当 edited 触发且所有检查均通过时,触发恢复流程 + all_clean = ( + has_unreachable == "false" + and network_status in ("ok", "skipped") + ) - _output_result( - labels_to_add, labels_to_remove, - should_close, should_reopen, - warning_comment, bot_comment, + if issue_action == "edited" and all_clean: + warning_type = "recovery" + warning_comment = build_recovery_comment(issue_user) + + _output( + has_snapshot=has_snapshot, + has_local_link=has_local_link, + has_unreachable=has_unreachable, + network_status=network_status, + network_detail=network_detail, + has_convertible=has_convertible, + warning_type=warning_type, + warning_comment=warning_comment, + bot_comment=bot_comment, ) +def _check_attachments( + attachment_links: list, + issue_user: str, + has_unreachable: str, + warning_type: str, + warning_comment: str, +) -> tuple[str, str, str, str]: + """ + 对 GitHub 附件链接执行网络检查。 + + 遵循 Fail Fast 原则:遇到 404 立即返回致命结果。 + 不确定结果(403/5xx)为非致命,记录但不中断。 + + 返回:(network_status, network_detail, warning_type, warning_comment) + """ + network_status = "ok" + network_detail = "" + uncertain_url = "" + uncertain_code = 0 + uncertain_detail = "" + + for lnk in attachment_links: + result = check_network_links(lnk.url) + + if result.status == "404": + return ( + "404", + "", + "inaccessible", + build_warning_inaccessible(issue_user, lnk.url), + ) + + if result.status == "uncertain": + if network_status != "uncertain": + network_status = "uncertain" + network_detail = f"HTTP {result.status_code}: {result.detail}" + uncertain_url = lnk.url + uncertain_code = result.status_code + uncertain_detail = result.detail + + if network_status == "uncertain": + uncertain_warning = build_warning_uncertain( + issue_user, uncertain_url, uncertain_code, uncertain_detail + ) + if warning_comment: + warning_comment += f"\n\n---\n\n{uncertain_warning}" + warning_type = "unreachable+uncertain" + else: + warning_comment = "\n" + uncertain_warning + warning_type = "uncertain" + + return network_status, network_detail, warning_type, warning_comment + + +def _output(**kwargs): + """将所有分析结果写入 GITHUB_OUTPUT。""" + for key, value in kwargs.items(): + write_output(key, value) + + if __name__ == "__main__": main() \ No newline at end of file diff --git a/scripts/python/checker.py b/scripts/python/checker.py index c2a174c06..c80ec72c2 100644 --- a/scripts/python/checker.py +++ b/scripts/python/checker.py @@ -5,6 +5,8 @@ 1. 本地链接检查:识别 localhost / 127.0.0.1 / file:// 等不可分享链接 2. 不可访问快照链接检查:识别 i.gkd.li/snapshot/ 链接 3. 网络有效性检查:对 GitHub 附件链接发起 HTTP 请求,验证可访问性 + +本模块只返回检查结果,不做任何业务判断(如是否关闭 Issue)。 """ from dataclasses import dataclass @@ -19,9 +21,9 @@ class NetworkResult: """网络请求检查结果""" - status: str # "ok" / "404" / "uncertain" - status_code: int = 0 - detail: str = "" + status: str # "ok" / "404" / "uncertain" + status_code: int = 0 # HTTP 状态码 + detail: str = "" # 错误详情(供折叠展示) # ── 本地链接检查 ── @@ -31,7 +33,7 @@ def check_local_links(links: list[LinkInfo]) -> list[LinkInfo]: """ 筛选出所有本地不可分享链接。 - 返回值为空列表时表示没有本地链接,可继续后续检查。 + 返回值为空列表时表示没有本地链接。 """ return [lnk for lnk in links if lnk.kind == "local"] @@ -95,13 +97,12 @@ def _try_head_request(url: str, timeout: int) -> NetworkResult | None: if e.code == 404: return NetworkResult(status="404", status_code=404) if e.code == 405: - # 服务器不支持 HEAD 方法,回退 return None if e.code == 403: return NetworkResult( status="uncertain", status_code=403, - detail=f"HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", ) if 500 <= e.code < 600: return NetworkResult( @@ -137,7 +138,6 @@ def _try_get_range_request(url: str, timeout: int) -> NetworkResult: req.add_header("Range", "bytes=0-0") with urllib.request.urlopen(req, timeout=timeout) as resp: code = resp.status - # 206 = Partial Content(正常),200 = 服务器忽略了 Range 头也算正常 if code in (200, 206): return NetworkResult(status="ok", status_code=code) return NetworkResult( @@ -152,7 +152,7 @@ def _try_get_range_request(url: str, timeout: int) -> NetworkResult: return NetworkResult( status="uncertain", status_code=403, - detail=f"HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", ) if 500 <= e.code < 600: return NetworkResult( diff --git a/scripts/python/converter.py b/scripts/python/converter.py index 6905904f0..b14ec9177 100644 --- a/scripts/python/converter.py +++ b/scripts/python/converter.py @@ -1,10 +1,12 @@ """ -链接转换与 Bot 评论生成模块 +链接转换模块 -负责: -1. 将 GitHub 附件链接转换为 GKD 代理链接 -2. 按文件名中的 App/Activity 分组 -3. 生成格式化的 Bot 评论内容 +将 GitHub 附件链接转换为 GKD 代理链接。 +仅处理 kind == "github_attachment" 的链接,GKD 链接原样保留。 + +转换公式:https://i.gkd.li/i?url={{原始GitHub附件URL}} + +本模块只负责数据转换,不负责评论格式化(由 formatter.py 处理)。 """ import re @@ -20,12 +22,12 @@ class ConvertedLink: """转换后的链接信息""" - original_url: str # 原始 GitHub 附件 URL - converted_url: str # 转换后的 GKD 代理 URL - display_text: str # 原始 Markdown 链接的显示文字 - app_name: str # 从文件名提取的 App 名称 - activity_name: str # 从文件名提取的 Activity 名称 - timestamp: str # 从文件名提取的时间戳 + original_url: str # 原始 GitHub 附件 URL + converted_url: str # 转换后的 GKD 代理 URL + display_text: str # 原始 Markdown 链接的显示文字 + app_name: str # 从文件名提取的 App 名称(不匹配时为空) + activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) + timestamp: str # 从文件名提取的时间戳(不匹配时为空) # ── 常量 ── @@ -33,18 +35,16 @@ class ConvertedLink: # GKD 代理链接模板 GKD_PROXY_TEMPLATE = "https://i.gkd.li/i?url={url}" -# 文件名模式:{App}_{Activity}-{timestamp}.zip -# 例如:QQ_SplashActivity-1781663723542.zip -_RE_FILENAME = re.compile( - r"https://github\.com/user-attachments/files/\d+/(.+)" -) +# 从 URL 中提取文件名的正则 +_RE_FILENAME = re.compile(r"https://github\.com/user-attachments/files/\d+/(.+)") +# 文件名模式:{App}_{Activity}-{timestamp}.zip _RE_NAME_PATTERN = re.compile( r"^(?P.+?)_(?P.+?)-(?P\d+)\.zip$" ) -# ── 转换逻辑 ── +# ── 转换函数 ── def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: @@ -60,6 +60,7 @@ def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: if lnk.kind != "github_attachment": continue + # 执行 URL 转换 converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) # 从 URL 中提取文件名 @@ -88,86 +89,4 @@ def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: ) ) - return results - - -# ── 评论生成 ── - - -def build_bot_comment(converted: list[ConvertedLink]) -> str: - """ - 生成 Bot 评论内容。 - - 格式: - ### AppName - #### ActivityName - [timestamp](转换后URL) ← 有 display_text 时用 [display_text](转换后URL) - - 底部
折叠所有原始附件 URL。 - """ - if not converted: - return "" - - # 按文件名模式分组:app → activity → [links] - grouped: dict[str, dict[str, list[ConvertedLink]]] = {} - ungrouped: list[ConvertedLink] = [] - - for item in converted: - if item.app_name and item.activity_name: - grouped.setdefault(item.app_name, {}).setdefault( - item.activity_name, [] - ).append(item) - else: - ungrouped.append(item) - - lines: list[str] = [] - - # 生成分组部分 - for app_name in _stable_key_order(grouped): - activities = grouped[app_name] - lines.append(f"### {app_name}") - for activity_name in _stable_key_order(activities): - items = activities[activity_name] - lines.append(f"#### {activity_name}") - for item in items: - lines.append(_format_link_line(item)) - lines.append("") - - # 生成未分组部分(文件名不匹配模式) - if ungrouped: - for item in ungrouped: - lines.append(_format_link_line(item)) - lines.append("") - - # 生成快速复制折叠区 - lines.append("
") - lines.append("快速复制") - lines.append("") - lines.append("## 快速复制") - lines.append("```") - for item in converted: - lines.append(item.original_url) - lines.append("```") - lines.append("
") - - return "\n".join(lines) - - -def _format_link_line(item: ConvertedLink) -> str: - """ - 格式化单条链接行。 - - - 有 display_text 时:[display_text](converted_url) - - 有 timestamp 时:[timestamp](converted_url) - - 否则:直接输出 converted_url - """ - if item.display_text: - return f"[{item.display_text}]({item.converted_url})" - if item.timestamp: - return f"[{item.timestamp}]({item.converted_url})" - return item.converted_url - - -def _stable_key_order(d: dict) -> list[str]: - """按插入顺序返回字典的键(Python 3.7+ dict 保持插入顺序)。""" - return list(d.keys()) \ No newline at end of file + return results \ No newline at end of file diff --git a/scripts/python/extractor.py b/scripts/python/extractor.py index 7cee88cb2..33d521ad9 100644 --- a/scripts/python/extractor.py +++ b/scripts/python/extractor.py @@ -1,23 +1,28 @@ """ -链接提取模块 +链接提取与分类模块 从 Issue Body 中提取所有快照相关链接,并分类为: - gkd:GKD 分享链接 (https://i.gkd.li/i/XXXXXXXX) - github_attachment:GitHub 附件链接 (github.com/user-attachments/files/) - local:不可分享的本地链接 (localhost / 127.0.0.1 / file://) - unreachable_snapshot:不可访问的快照链接 (i.gkd.li/snapshot/) + +本模块只负责提取和分类,不做任何检查或判断。 """ import re from dataclasses import dataclass +# ── 数据结构 ── + + @dataclass class LinkInfo: """提取出的单条链接信息""" - url: str - kind: str # gkd / github_attachment / local / unreachable_snapshot + url: str # 完整 URL + kind: str # 分类:gkd / github_attachment / local / unreachable_snapshot display_text: str # Markdown 链接的显示文字,纯文本时为空 @@ -45,6 +50,9 @@ class LinkInfo: ) +# ── 分类函数 ── + + def _classify_url(url: str) -> str | None: """ 对单个 URL 进行分类。 @@ -54,7 +62,7 @@ def _classify_url(url: str) -> str | None: - "github_attachment":GitHub 附件链接 - "local":本地不可分享链接 - "unreachable_snapshot":不可访问的快照链接 - - None:不属于以上任何类别 + - None:不属于以上任何类别(忽略) """ if _RE_LOCAL_LINK.match(url): return "local" @@ -67,6 +75,9 @@ def _classify_url(url: str) -> str | None: return None +# ── 主提取函数 ── + + def extract_links(body: str) -> list[LinkInfo]: """ 从 Issue Body 中提取所有快照相关链接。 @@ -74,11 +85,13 @@ def extract_links(body: str) -> list[LinkInfo]: 处理两种格式: 1. Markdown 链接:[文字](URL) → 保留显示文字 2. 纯文本 URL:直接匹配 → display_text 为空 + + 去重策略:同一 URL 只保留首次出现。 """ seen: set[str] = set() results: list[LinkInfo] = [] - # 先提取 Markdown 格式链接 + # 先提取 Markdown 格式链接(优先保留显示文字) for match in _RE_MD_LINK.finditer(body): display_text = match.group(1) url = match.group(2) diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py new file mode 100644 index 000000000..56eb146ca --- /dev/null +++ b/scripts/python/formatter.py @@ -0,0 +1,156 @@ +""" +评论格式化模块 + +负责生成所有 Bot 评论的 Markdown 内容,包括: +- 各类警告评论(缺失快照 / 本地链接 / 不可访问快照 / 链接无法访问 / 不确定) +- 编辑恢复评论 +- 快照转换 Bot 评论(按 App > Activity 分组) + +本模块只负责内容生成,不负责评论发布(由 YAML 工作流完成)。 +""" + +from converter import ConvertedLink + + +# ── 警告评论生成 ── + + +def build_warning_missing(user: str) -> str: + """缺失快照时的警告评论""" + return ( + "\n" + f"您好 @{user},由于您没有提供快照链接,此 Issue 已被自动关闭。\n\n" + "请提供正确的快照链接后重新打开或提交新的 Issue。" + ) + + +def build_warning_local(user: str) -> str: + """检测到本地链接时的警告评论""" + return ( + "\n" + f"您好 @{user},检测到您使用了不可分享的本地链接" + "(如 localhost、127.0.0.1、file:// 等),他人无法访问该链接," + "此 Issue 已被自动关闭。\n\n" + "请使用正确的分享方式上传快照后重新提交。" + ) + + +def build_warning_unreachable(user: str) -> str: + """检测到 i.gkd.li/snapshot/ 时的提醒评论(不关闭)""" + return ( + "\n" + f"您好 @{user},检测到您提供了他人无法访问的快照链接" + "(i.gkd.li/snapshot/),请点击查看 " + "[正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。" + "可在下方评论区补充。" + ) + + +def build_warning_inaccessible(user: str, url: str) -> str: + """链接不可访问(404)时的警告评论""" + return ( + "\n" + f"您好 @{user},检测到您提供的快照链接无法访问:\n\n" + f"`{url}`\n\n" + "此 Issue 已被自动关闭。请确认链接正确后重新提交。" + ) + + +def build_warning_uncertain( + user: str, url: str, status_code: int, detail: str +) -> str: + """链接返回不确定状态码时的提醒评论(不关闭,折叠错误详情)""" + return ( + f"您好 @{user},检测到快照链接访问异常(HTTP {status_code})," + "暂时无法确认链接是否有效,请人工核查:\n\n" + f"`{url}`\n\n" + f"
\n详细错误信息\n\n```\n{detail}\n```\n
" + ) + + +def build_recovery_comment(user: str) -> str: + """编辑修正后检查通过时的恢复评论""" + return ( + "\n" + f"✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" + ) + + +# ── Bot 转换评论生成 ── + + +def build_bot_comment(converted: list[ConvertedLink]) -> str: + """ + 生成快照转换 Bot 评论内容。 + + 格式: + ### AppName + #### ActivityName + [timestamp](转换后URL) 或 [display_text](转换后URL) + + 不匹配文件名模式的附件不分组,逐条列出。 + + 底部
折叠区包含所有原始附件 URL(快速复制)。 + """ + if not converted: + return "" + + # 按文件名模式分组:app → activity → [links] + grouped: dict[str, dict[str, list[ConvertedLink]]] = {} + ungrouped: list[ConvertedLink] = [] + + for item in converted: + if item.app_name and item.activity_name: + grouped.setdefault(item.app_name, {}).setdefault( + item.activity_name, [] + ).append(item) + else: + ungrouped.append(item) + + lines: list[str] = [] + + # 生成分组部分 + for app_name in grouped: + activities = grouped[app_name] + lines.append(f"### {app_name}") + for activity_name in activities: + items = activities[activity_name] + lines.append(f"#### {activity_name}") + for item in items: + lines.append(_format_link_line(item)) + lines.append("") + + # 生成未分组部分(文件名不匹配模式) + if ungrouped: + for item in ungrouped: + lines.append(_format_link_line(item)) + lines.append("") + + # 生成快速复制折叠区 + lines.append("
") + lines.append("快速复制") + lines.append("") + lines.append("## 快速复制") + lines.append("```") + for item in converted: + lines.append(item.original_url) + lines.append("```") + lines.append("
") + + return "\n".join(lines) + + +def _format_link_line(item: ConvertedLink) -> str: + """ + 格式化单条链接行。 + + 优先级: + 1. 有 display_text 时:[display_text](converted_url) + 2. 有 timestamp 时:[timestamp](converted_url) + 3. 否则:直接输出 converted_url + """ + if item.display_text: + return f"[{item.display_text}]({item.converted_url})" + if item.timestamp: + return f"[{item.timestamp}]({item.converted_url})" + return item.converted_url \ No newline at end of file diff --git a/scripts/python/utils.py b/scripts/python/utils.py new file mode 100644 index 000000000..65c34cdc8 --- /dev/null +++ b/scripts/python/utils.py @@ -0,0 +1,28 @@ +""" +公共工具模块 + +提供 GITHUB_OUTPUT 写入等共享工具函数,供其他模块调用。 +本模块不包含任何业务逻辑。 +""" + +import os + + +# ── 本工作流管理的所有标签 ── + +MANAGED_LABELS = [ + "缺失快照(missing-snapshot)", + "本地链接(local-link)", + "需补充链接(need-supplement-link)", + "链接无法访问(inaccessible-link)", +] + + +def write_output(key: str, value: str): + """ + 向 GITHUB_OUTPUT 写入一个键值对。 + + 使用 heredoc 语法支持多行值,确保 Markdown 内容正确传递。 + """ + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"{key}< Date: Thu, 9 Jul 2026 19:01:15 +0800 Subject: [PATCH 05/90] =?UTF-8?q?refactor:=20issue=20CI=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 180 ++++++++++++++++++---- 1 file changed, 147 insertions(+), 33 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index c6012929e..4cb1fbb1f 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -21,7 +21,7 @@ jobs: with: python-version: '3.12' - # ── 核心分析(Python 负责)── + # ── 核心分析(Python 只运行一次,输出原子化标志)── - name: 分析 Issue 快照链接 id: analyze @@ -31,60 +31,174 @@ jobs: ISSUE_ACTION: ${{ github.action }} run: python3 scripts/python/check_issue.py - # ── GitHub 操作(Actions / gh CLI 负责)── + # ── 查找已有评论(两种标记各查一次)── - - name: 添加标签 - if: steps.analyze.outputs.labels_to_add != '' + - name: 查找已有警告评论 + id: find-warning + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: 查找已有 Bot 评论 + id: find-bot + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + # ── 场景一:缺失快照 → 打标签 + 评论 + 关闭 ── + + - name: 打标签「缺失快照」 + if: steps.analyze.outputs.has_snapshot == 'false' env: - LABELS: ${{ steps.analyze.outputs.labels_to_add }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue edit "$ISSUE_NUMBER" --add-label "缺失快照(missing-snapshot)" + + - name: 发布缺失快照警告评论 + if: steps.analyze.outputs.has_snapshot == 'false' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-warning.outputs.comment-id }} + issue-number: ${{ github.event.issue.number }} + body: ${{ steps.analyze.outputs.warning_comment }} + edit-mode: replace + + - name: 关闭 Issue(缺失快照) + if: steps.analyze.outputs.has_snapshot == 'false' + env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - IFS=',' read -ra LABELS <<< "$LABELS" - for label in "${LABELS[@]}"; do - gh issue edit "$ISSUE_NUMBER" --add-label "$label" - done + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue close "$ISSUE_NUMBER" --reason "not planned" + + # ── 场景二:本地链接 → 打标签 + 评论 + 关闭 ── + + - name: 打标签「本地链接」 + if: steps.analyze.outputs.has_local_link == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue edit "$ISSUE_NUMBER" --add-label "本地链接(local-link)" + + - name: 发布本地链接警告评论 + if: steps.analyze.outputs.has_local_link == 'true' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-warning.outputs.comment-id }} + issue-number: ${{ github.event.issue.number }} + body: ${{ steps.analyze.outputs.warning_comment }} + edit-mode: replace - - name: 移除旧标签 - if: steps.analyze.outputs.labels_to_remove != '' + - name: 关闭 Issue(本地链接) + if: steps.analyze.outputs.has_local_link == 'true' env: - LABELS: ${{ steps.analyze.outputs.labels_to_remove }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue close "$ISSUE_NUMBER" --reason "not planned" + + # ── 场景三:不可访问快照 → 打标签 + 评论(不关闭)── + + - name: 打标签「需补充链接」 + if: steps.analyze.outputs.has_unreachable == 'true' + env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - IFS=',' read -ra LABELS <<< "$LABELS" - for label in "${LABELS[@]}"; do - gh issue edit "$ISSUE_NUMBER" --remove-label "$label" || true - done + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue edit "$ISSUE_NUMBER" --add-label "需补充链接(need-supplement-link)" - - name: 发布/更新警告评论 - if: steps.analyze.outputs.warning_comment != '' - uses: peter-evans/create-or-update-comment@v4 + - name: 发布不可访问快照提醒评论 + if: steps.analyze.outputs.has_unreachable == 'true' + uses: peter-evans/create-or-update-comment@v5 with: + comment-id: ${{ steps.find-warning.outputs.comment-id }} + issue-number: ${{ github.event.issue.number }} + body: ${{ steps.analyze.outputs.warning_comment }} + edit-mode: replace + + # ── 场景四:链接不可访问(404)→ 打标签 + 评论 + 关闭 ── + + - name: 打标签「链接无法访问」 + if: steps.analyze.outputs.network_status == '404' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue edit "$ISSUE_NUMBER" --add-label "链接无法访问(inaccessible-link)" + + - name: 发布链接不可访问警告评论 + if: steps.analyze.outputs.network_status == '404' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-warning.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - comment-author: 'github-actions[bot]' - body-includes: '' body: ${{ steps.analyze.outputs.warning_comment }} + edit-mode: replace + + - name: 关闭 Issue(链接不可访问) + if: steps.analyze.outputs.network_status == '404' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue close "$ISSUE_NUMBER" --reason "not planned" + + # ── 场景五:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── + + - name: 打标签「链接无法访问」(不确定) + if: steps.analyze.outputs.network_status == 'uncertain' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue edit "$ISSUE_NUMBER" --add-label "链接无法访问(inaccessible-link)" + + - name: 发布网络不确定提醒评论 + if: steps.analyze.outputs.network_status == 'uncertain' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-warning.outputs.comment-id }} + issue-number: ${{ github.event.issue.number }} + body: ${{ steps.analyze.outputs.warning_comment }} + edit-mode: replace + + # ── 场景六:链接转换 + Bot 评论(检查全部通过)── - name: 发布/更新快照转换评论 - if: steps.analyze.outputs.bot_comment != '' - uses: peter-evans/create-or-update-comment@v4 + if: steps.analyze.outputs.has_convertible == 'true' + uses: peter-evans/create-or-update-comment@v5 with: + comment-id: ${{ steps.find-bot.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - comment-author: 'github-actions[bot]' - body-includes: '' body: ${{ steps.analyze.outputs.bot_comment }} + edit-mode: replace + + # ── 场景七:编辑恢复(移除旧标签 + 重新打开 Issue)── - - name: 关闭 Issue - if: steps.analyze.outputs.should_close == 'true' + - name: 移除所有本工作流管理的标签 + if: steps.analyze.outputs.warning_type == 'recovery' env: - ISSUE_NUMBER: ${{ github.event.issue.number }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh issue close "$ISSUE_NUMBER" --reason "not planned" + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + for label in \ + "缺失快照(missing-snapshot)" \ + "本地链接(local-link)" \ + "需补充链接(need-supplement-link)" \ + "链接无法访问(inaccessible-link)"; do + gh issue edit "$ISSUE_NUMBER" --remove-label "$label" 2>/dev/null || true + done - name: 重新打开 Issue - if: steps.analyze.outputs.should_reopen == 'true' + if: steps.analyze.outputs.warning_type == 'recovery' env: - ISSUE_NUMBER: ${{ github.event.issue.number }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue reopen "$ISSUE_NUMBER" || true + + - name: 发布恢复评论 + if: steps.analyze.outputs.warning_type == 'recovery' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-warning.outputs.comment-id }} + issue-number: ${{ github.event.issue.number }} + body: ${{ steps.analyze.outputs.warning_comment }} + edit-mode: replace From 7e518bd616f6e4cb50683b874b401c646580944b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 20:30:22 +0800 Subject: [PATCH 06/90] =?UTF-8?q?chore:=20=E6=8E=92=E9=99=A4pycache?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f8860d19d..2a4403a66 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ lerna-debug.log* node_modules package-lock.json -yarn.lock \ No newline at end of file +yarn.lock + +__pycache__/ \ No newline at end of file From ec7e32b4d6bd2da8313a69f121f9e6e0870b362b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 20:32:10 +0800 Subject: [PATCH 07/90] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8job=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 193 +++++++++++++++------- .trae/rules/project_rules.md | 177 ++++++++++++++------ scripts/python/check_issue.py | 186 +++++++++++---------- scripts/python/formatter.py | 23 ++- 4 files changed, 369 insertions(+), 210 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 4cb1fbb1f..d47f4cf96 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -8,11 +8,26 @@ permissions: issues: write jobs: - check-issue-content: + # ── 分析节点:Python 只运行一次,输出所有原子化标志 ── + + analyze: runs-on: ubuntu-latest + outputs: + has_snapshot: ${{ steps.analyze.outputs.has_snapshot }} + has_local_link: ${{ steps.analyze.outputs.has_local_link }} + has_unreachable: ${{ steps.analyze.outputs.has_unreachable }} + network_status: ${{ steps.analyze.outputs.network_status }} + network_detail: ${{ steps.analyze.outputs.network_detail }} + has_convertible: ${{ steps.analyze.outputs.has_convertible }} + warning_type: ${{ steps.analyze.outputs.warning_type }} + comment_missing: ${{ steps.analyze.outputs.comment_missing }} + comment_local: ${{ steps.analyze.outputs.comment_local }} + comment_unreachable: ${{ steps.analyze.outputs.comment_unreachable }} + comment_404: ${{ steps.analyze.outputs.comment_404 }} + comment_uncertain: ${{ steps.analyze.outputs.comment_uncertain }} + comment_recovery: ${{ steps.analyze.outputs.comment_recovery }} + comment_bot: ${{ steps.analyze.outputs.comment_bot }} steps: - # ── 环境准备 ── - - name: 检出代码仓库 uses: actions/checkout@v4 @@ -21,17 +36,26 @@ jobs: with: python-version: '3.12' - # ── 核心分析(Python 只运行一次,输出原子化标志)── - - name: 分析 Issue 快照链接 id: analyze env: ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_USER: ${{ github.event.issue.user.login }} - ISSUE_ACTION: ${{ github.action }} + ISSUE_ACTION: ${{ github.event.action }} run: python3 scripts/python/check_issue.py - # ── 查找已有评论(两种标记各查一次)── + # ── 处理分支一:缺失快照 → 打标签 + 评论 + 关闭 ── + + handle-missing-snapshot: + needs: analyze + if: needs.analyze.outputs.has_snapshot == 'false' + runs-on: ubuntu-latest + steps: + - name: 打标签「缺失快照」 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue edit "$ISSUE_NUMBER" --add-label "缺失快照(missing-snapshot)" - name: 查找已有警告评论 id: find-warning @@ -39,142 +63,179 @@ jobs: with: issue-number: ${{ github.event.issue.number }} comment-author: 'github-actions[bot]' - body-includes: '' - - - name: 查找已有 Bot 评论 - id: find-bot - uses: peter-evans/find-comment@v4 - with: - issue-number: ${{ github.event.issue.number }} - comment-author: 'github-actions[bot]' - body-includes: '' - - # ── 场景一:缺失快照 → 打标签 + 评论 + 关闭 ── - - - name: 打标签「缺失快照」 - if: steps.analyze.outputs.has_snapshot == 'false' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue edit "$ISSUE_NUMBER" --add-label "缺失快照(missing-snapshot)" + body-includes: '' - name: 发布缺失快照警告评论 - if: steps.analyze.outputs.has_snapshot == 'false' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - body: ${{ steps.analyze.outputs.warning_comment }} + body: ${{ needs.analyze.outputs.comment_missing }} edit-mode: replace - - name: 关闭 Issue(缺失快照) - if: steps.analyze.outputs.has_snapshot == 'false' + - name: 关闭 Issue(缺失快照) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - # ── 场景二:本地链接 → 打标签 + 评论 + 关闭 ── + # ── 处理分支二:本地链接 → 打标签 + 评论(不关闭)── + handle-local-link: + needs: analyze + if: > + needs.analyze.outputs.has_local_link == 'true' && + needs.analyze.outputs.network_status != '404' + runs-on: ubuntu-latest + steps: - name: 打标签「本地链接」 - if: steps.analyze.outputs.has_local_link == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue edit "$ISSUE_NUMBER" --add-label "本地链接(local-link)" + - name: 查找已有警告评论 + id: find-warning + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + - name: 发布本地链接警告评论 - if: steps.analyze.outputs.has_local_link == 'true' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - body: ${{ steps.analyze.outputs.warning_comment }} + body: ${{ needs.analyze.outputs.comment_local }} edit-mode: replace - - name: 关闭 Issue(本地链接) - if: steps.analyze.outputs.has_local_link == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - - # ── 场景三:不可访问快照 → 打标签 + 评论(不关闭)── + # ── 处理分支三:不可访问快照 → 打标签 + 评论(不关闭)── + handle-unreachable-snapshot: + needs: analyze + if: > + needs.analyze.outputs.has_unreachable == 'true' && + needs.analyze.outputs.network_status != '404' + runs-on: ubuntu-latest + steps: - name: 打标签「需补充链接」 - if: steps.analyze.outputs.has_unreachable == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue edit "$ISSUE_NUMBER" --add-label "需补充链接(need-supplement-link)" + - name: 查找已有警告评论 + id: find-warning + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + - name: 发布不可访问快照提醒评论 - if: steps.analyze.outputs.has_unreachable == 'true' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - body: ${{ steps.analyze.outputs.warning_comment }} + body: ${{ needs.analyze.outputs.comment_unreachable }} edit-mode: replace - # ── 场景四:链接不可访问(404)→ 打标签 + 评论 + 关闭 ── + # ── 处理分支四:链接不可访问(404)→ 打标签 + 评论 + 关闭 ── + handle-network-404: + needs: analyze + if: needs.analyze.outputs.network_status == '404' + runs-on: ubuntu-latest + steps: - name: 打标签「链接无法访问」 - if: steps.analyze.outputs.network_status == '404' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue edit "$ISSUE_NUMBER" --add-label "链接无法访问(inaccessible-link)" + - name: 查找已有警告评论 + id: find-warning + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + - name: 发布链接不可访问警告评论 - if: steps.analyze.outputs.network_status == '404' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - body: ${{ steps.analyze.outputs.warning_comment }} + body: ${{ needs.analyze.outputs.comment_404 }} edit-mode: replace - - name: 关闭 Issue(链接不可访问) - if: steps.analyze.outputs.network_status == '404' + - name: 关闭 Issue(链接不可访问) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - # ── 场景五:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── + # ── 处理分支五:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── - - name: 打标签「链接无法访问」(不确定) - if: steps.analyze.outputs.network_status == 'uncertain' + handle-network-uncertain: + needs: analyze + if: needs.analyze.outputs.network_status == 'uncertain' + runs-on: ubuntu-latest + steps: + - name: 打标签「链接无法访问」(不确定) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue edit "$ISSUE_NUMBER" --add-label "链接无法访问(inaccessible-link)" + - name: 查找已有警告评论 + id: find-warning + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + - name: 发布网络不确定提醒评论 - if: steps.analyze.outputs.network_status == 'uncertain' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - body: ${{ steps.analyze.outputs.warning_comment }} + body: ${{ needs.analyze.outputs.comment_uncertain }} edit-mode: replace - # ── 场景六:链接转换 + Bot 评论(检查全部通过)── + # ── 处理分支六:链接转换 + Bot 评论(检查通过)── + + handle-convert: + needs: analyze + if: needs.analyze.outputs.has_convertible == 'true' + runs-on: ubuntu-latest + steps: + - name: 查找已有 Bot 评论 + id: find-bot + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' - name: 发布/更新快照转换评论 - if: steps.analyze.outputs.has_convertible == 'true' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-bot.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} - body: ${{ steps.analyze.outputs.bot_comment }} + body: ${{ needs.analyze.outputs.comment_bot }} edit-mode: replace - # ── 场景七:编辑恢复(移除旧标签 + 重新打开 Issue)── + # ── 处理分支七:编辑恢复(移除旧标签 + 重新打开 Issue)── + handle-recovery: + needs: analyze + if: needs.analyze.outputs.warning_type == 'recovery' + runs-on: ubuntu-latest + steps: - name: 移除所有本工作流管理的标签 - if: steps.analyze.outputs.warning_type == 'recovery' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} @@ -188,17 +249,23 @@ jobs: done - name: 重新打开 Issue - if: steps.analyze.outputs.warning_type == 'recovery' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue reopen "$ISSUE_NUMBER" || true + - name: 查找已有警告评论 + id: find-warning + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '` — 缺失快照 +- `` — 本地链接 +- `` — 不可访问快照 +- `` — 链接 404 +- `` — 网络不确定 +- `` — 编辑恢复 +- `` — Bot 转换评论 + +恢复场景使用 `` 标记) | -| `bot_comment` | string | Bot 评论 Markdown(含 `` 标记) | +| 变量名 | 类型 | 含义 | +| --------------------- | ------ | ------------------------------------------------------------------------------------------------ | +| `has_snapshot` | bool | 是否包含任何快照链接 | +| `has_local_link` | bool | 是否包含本地链接 | +| `has_unreachable` | bool | 是否包含不可访问快照 | +| `network_status` | string | 网络检查结果:`ok` / `404` / `uncertain` / `skipped` | +| `network_detail` | string | 网络错误详情(折叠展示用) | +| `has_convertible` | bool | 是否有可转换的 GitHub 附件 | +| `warning_type` | string | 警告类型:`missing` / `local` / `unreachable` / `inaccessible` / `uncertain` / `recovery` / `""` | +| `comment_missing` | string | 缺失快照评论 Markdown(含 `` 标记) | +| `comment_local` | string | 本地链接评论 Markdown(含 `` 标记) | +| `comment_unreachable` | string | 不可访问快照评论 Markdown(含 `` 标记) | +| `comment_404` | string | 链接 404 评论 Markdown(含 `` 标记) | +| `comment_uncertain` | string | 网络不确定评论 Markdown(含 `` 标记) | +| `comment_recovery` | string | 恢复评论 Markdown(含 `` 标记) | +| `comment_bot` | string | Bot 评论 Markdown(含 `` 标记) | --- @@ -135,7 +218,7 @@ YAML 根据这些标志决定执行哪些 Step。 | 场景 | 标签名 | 是否关闭 Issue | | ------------------------- | ---------------------------------- | ---------------------- | | 缺失快照 | `缺失快照(missing-snapshot)` | ✅ 关闭(not planned) | -| 本地链接 | `本地链接(local-link)` | ✅ 关闭(not planned) | +| 本地链接 | `本地链接(local-link)` | ❌ 不关闭 | | 不可访问快照链接 | `需补充链接(need-supplement-link)` | ❌ 不关闭 | | 链接无法访问(404/403/5xx) | `链接无法访问(inaccessible-link)` | 404关闭,403/5xx不关闭 | diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py index 7e5915f9a..b8a9f1966 100644 --- a/scripts/python/check_issue.py +++ b/scripts/python/check_issue.py @@ -6,25 +6,31 @@ 流程(Fail Fast,遇到致命错误立即停止): 1. 提取链接 → 判断是否缺少快照(致命) - 2. 检查本地链接(致命) + 2. 检查本地链接(非致命,不提前返回) 3. 检查不可访问快照链接(非致命,继续) 4. 网络有效性检查(404 致命 / 不确定非致命) 5. 链接转换 + Bot 评论生成 6. 编辑恢复判断 -输出变量(原子化标志,供 YAML 工作流条件判断): - - has_snapshot : 是否包含任何快照链接 - - has_local_link : 是否包含本地链接 - - has_unreachable : 是否包含不可访问快照 - - network_status : 网络检查结果 (ok / 404 / uncertain / skipped) - - network_detail : 网络错误详情 - - has_convertible : 是否有可转换的 GitHub 附件 - - warning_type : 警告类型 (missing / local / unreachable / inaccessible / uncertain / recovery / "") - - warning_comment : 警告评论 Markdown(含 标记) - - bot_comment : Bot 评论 Markdown(含 标记) +输出变量(原子化标志,供 YAML 多 Job 条件判断): + - has_snapshot : 是否包含任何快照链接 + - has_local_link : 是否包含本地链接 + - has_unreachable : 是否包含不可访问快照 + - network_status : 网络检查结果 (ok / 404 / uncertain / skipped) + - network_detail : 网络错误详情 + - has_convertible : 是否有可转换的 GitHub 附件 + - warning_type : 警告类型 (missing / local / unreachable / inaccessible / uncertain / recovery / "") + - comment_missing : 缺失快照评论(含 标记) + - comment_local : 本地链接评论(含 标记) + - comment_unreachable : 不可访问快照评论(含 标记) + - comment_404 : 链接404评论(含 标记) + - comment_uncertain : 网络不确定评论(含 标记) + - comment_recovery : 恢复评论(含 标记) + - comment_bot : Bot 评论(含 标记) """ import os +from dataclasses import dataclass from extractor import extract_links from checker import check_local_links, check_unreachable_links, check_network_links @@ -46,6 +52,21 @@ _SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot", "local"} +# ── 网络检查聚合结果 ── + + +@dataclass +class _NetworkCheckResult: + """网络检查聚合结果,记录首次遇到的致命/不确定错误""" + + status: str = "ok" + detail: str = "" + fail_url: str = "" + uncertain_url: str = "" + uncertain_code: int = 0 + uncertain_detail: str = "" + + # ── 主流程 ── @@ -62,8 +83,13 @@ def main(): network_detail = "" has_convertible = "false" warning_type = "" - warning_comment = "" - bot_comment = "" + comment_missing = "" + comment_local = "" + comment_unreachable = "" + comment_404 = "" + comment_uncertain = "" + comment_recovery = "" + comment_bot = "" # ── 第一步:提取所有链接 ── links = extract_links(body) @@ -72,6 +98,7 @@ def main(): has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) if not has_any_snapshot: + comment_missing = build_warning_missing(issue_user) _output( has_snapshot="false", has_local_link="false", @@ -80,48 +107,39 @@ def main(): network_detail="", has_convertible="false", warning_type="missing", - warning_comment=build_warning_missing(issue_user), - bot_comment="", + comment_missing=comment_missing, + comment_local="", + comment_unreachable="", + comment_404="", + comment_uncertain="", + comment_recovery="", + comment_bot="", ) return - # ── 第三步:检查本地链接(致命 → 提前返回) ── + # ── 第三步:检查本地链接(非致命,不提前返回) ── local_links = check_local_links(links) - + has_local_link = "true" if local_links else "false" if local_links: - _output( - has_snapshot="true", - has_local_link="true", - has_unreachable="false", - network_status="skipped", - network_detail="", - has_convertible="false", - warning_type="local", - warning_comment=build_warning_local(issue_user), - bot_comment="", - ) - return + comment_local = build_warning_local(issue_user) # ── 第四步:检查不可访问快照链接(非致命,继续后续检查) ── unreachable_links = check_unreachable_links(links) has_unreachable = "true" if unreachable_links else "false" - if unreachable_links: - warning_type = "unreachable" - warning_comment = build_warning_unreachable(issue_user) + comment_unreachable = build_warning_unreachable(issue_user) # ── 第五步:网络有效性检查(仅 GitHub 附件) ── attachment_links = [lnk for lnk in links if lnk.kind == "github_attachment"] if attachment_links: - network_status, network_detail, warning_type, warning_comment = ( - _check_attachments( - attachment_links, issue_user, has_unreachable, warning_type, warning_comment - ) - ) + net_result = _check_attachments(attachment_links) + network_status = net_result.status + network_detail = net_result.detail # 404 是致命错误 → 提前返回 if network_status == "404": + comment_404 = build_warning_inaccessible(issue_user, net_result.fail_url) _output( has_snapshot=has_snapshot, has_local_link=has_local_link, @@ -129,31 +147,45 @@ def main(): network_status=network_status, network_detail=network_detail, has_convertible="false", - warning_type=warning_type, - warning_comment=warning_comment, - bot_comment="", + warning_type="inaccessible", + comment_missing=comment_missing, + comment_local=comment_local, + comment_unreachable=comment_unreachable, + comment_404=comment_404, + comment_uncertain="", + comment_recovery="", + comment_bot="", ) return + # 不确定状态 → 生成评论,但不中断流程 + if network_status == "uncertain": + comment_uncertain = build_warning_uncertain( + issue_user, + net_result.uncertain_url, + net_result.uncertain_code, + net_result.uncertain_detail, + ) + # ── 第六步:链接转换 + Bot 评论生成(仅当网络检查通过时) ── if network_status in ("ok", "skipped") and attachment_links: converted = convert_github_attachments(attachment_links) has_convertible = "true" if converted else "false" - if converted: comment_body = build_bot_comment(converted) - bot_comment = "\n" + comment_body + comment_bot = "\n" + comment_body # ── 第七步:编辑恢复判断 ── # 当 edited 触发且所有检查均通过时,触发恢复流程 all_clean = ( has_unreachable == "false" and network_status in ("ok", "skipped") + and has_local_link == "false" ) if issue_action == "edited" and all_clean: warning_type = "recovery" - warning_comment = build_recovery_comment(issue_user) + comment_recovery = build_recovery_comment(issue_user) _output( has_snapshot=has_snapshot, @@ -163,63 +195,43 @@ def main(): network_detail=network_detail, has_convertible=has_convertible, warning_type=warning_type, - warning_comment=warning_comment, - bot_comment=bot_comment, + comment_missing=comment_missing, + comment_local=comment_local, + comment_unreachable=comment_unreachable, + comment_404=comment_404, + comment_uncertain=comment_uncertain, + comment_recovery=comment_recovery, + comment_bot=comment_bot, ) -def _check_attachments( - attachment_links: list, - issue_user: str, - has_unreachable: str, - warning_type: str, - warning_comment: str, -) -> tuple[str, str, str, str]: +def _check_attachments(attachment_links: list) -> _NetworkCheckResult: """ 对 GitHub 附件链接执行网络检查。 遵循 Fail Fast 原则:遇到 404 立即返回致命结果。 不确定结果(403/5xx)为非致命,记录但不中断。 - 返回:(network_status, network_detail, warning_type, warning_comment) + 返回:_NetworkCheckResult 聚合结果 """ - network_status = "ok" - network_detail = "" - uncertain_url = "" - uncertain_code = 0 - uncertain_detail = "" + result = _NetworkCheckResult() for lnk in attachment_links: - result = check_network_links(lnk.url) - - if result.status == "404": - return ( - "404", - "", - "inaccessible", - build_warning_inaccessible(issue_user, lnk.url), - ) + check = check_network_links(lnk.url) - if result.status == "uncertain": - if network_status != "uncertain": - network_status = "uncertain" - network_detail = f"HTTP {result.status_code}: {result.detail}" - uncertain_url = lnk.url - uncertain_code = result.status_code - uncertain_detail = result.detail - - if network_status == "uncertain": - uncertain_warning = build_warning_uncertain( - issue_user, uncertain_url, uncertain_code, uncertain_detail - ) - if warning_comment: - warning_comment += f"\n\n---\n\n{uncertain_warning}" - warning_type = "unreachable+uncertain" - else: - warning_comment = "\n" + uncertain_warning - warning_type = "uncertain" - - return network_status, network_detail, warning_type, warning_comment + if check.status == "404": + result.status = "404" + result.fail_url = lnk.url + return result + + if check.status == "uncertain" and result.status != "uncertain": + result.status = "uncertain" + result.detail = f"HTTP {check.status_code}: {check.detail}" + result.uncertain_url = lnk.url + result.uncertain_code = check.status_code + result.uncertain_detail = check.detail + + return result def _output(**kwargs): diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index 56eb146ca..c9cbea28d 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -6,6 +6,7 @@ - 编辑恢复评论 - 快照转换 Bot 评论(按 App > Activity 分组) +每类评论使用独立的 HTML 标记,供 YAML 工作流中 find-comment 按场景查找。 本模块只负责内容生成,不负责评论发布(由 YAML 工作流完成)。 """ @@ -18,27 +19,26 @@ def build_warning_missing(user: str) -> str: """缺失快照时的警告评论""" return ( - "\n" + "\n" f"您好 @{user},由于您没有提供快照链接,此 Issue 已被自动关闭。\n\n" "请提供正确的快照链接后重新打开或提交新的 Issue。" ) def build_warning_local(user: str) -> str: - """检测到本地链接时的警告评论""" + """检测到本地链接时的警告评论(不关闭 Issue)""" return ( - "\n" + "\n" f"您好 @{user},检测到您使用了不可分享的本地链接" - "(如 localhost、127.0.0.1、file:// 等),他人无法访问该链接," - "此 Issue 已被自动关闭。\n\n" - "请使用正确的分享方式上传快照后重新提交。" + "(如 localhost、127.0.0.1、file:// 等),他人无法访问该链接。\n\n" + "请使用正确的分享方式上传快照。" ) def build_warning_unreachable(user: str) -> str: """检测到 i.gkd.li/snapshot/ 时的提醒评论(不关闭)""" return ( - "\n" + "\n" f"您好 @{user},检测到您提供了他人无法访问的快照链接" "(i.gkd.li/snapshot/),请点击查看 " "[正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。" @@ -49,7 +49,7 @@ def build_warning_unreachable(user: str) -> str: def build_warning_inaccessible(user: str, url: str) -> str: """链接不可访问(404)时的警告评论""" return ( - "\n" + "\n" f"您好 @{user},检测到您提供的快照链接无法访问:\n\n" f"`{url}`\n\n" "此 Issue 已被自动关闭。请确认链接正确后重新提交。" @@ -61,6 +61,7 @@ def build_warning_uncertain( ) -> str: """链接返回不确定状态码时的提醒评论(不关闭,折叠错误详情)""" return ( + "\n" f"您好 @{user},检测到快照链接访问异常(HTTP {status_code})," "暂时无法确认链接是否有效,请人工核查:\n\n" f"`{url}`\n\n" @@ -71,7 +72,7 @@ def build_warning_uncertain( def build_recovery_comment(user: str) -> str: """编辑修正后检查通过时的恢复评论""" return ( - "\n" + "\n" f"✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" ) @@ -95,7 +96,6 @@ def build_bot_comment(converted: list[ConvertedLink]) -> str: if not converted: return "" - # 按文件名模式分组:app → activity → [links] grouped: dict[str, dict[str, list[ConvertedLink]]] = {} ungrouped: list[ConvertedLink] = [] @@ -109,7 +109,6 @@ def build_bot_comment(converted: list[ConvertedLink]) -> str: lines: list[str] = [] - # 生成分组部分 for app_name in grouped: activities = grouped[app_name] lines.append(f"### {app_name}") @@ -120,13 +119,11 @@ def build_bot_comment(converted: list[ConvertedLink]) -> str: lines.append(_format_link_line(item)) lines.append("") - # 生成未分组部分(文件名不匹配模式) if ungrouped: for item in ungrouped: lines.append(_format_link_line(item)) lines.append("") - # 生成快速复制折叠区 lines.append("
") lines.append("快速复制") lines.append("") From 67cbe3ac51a6e0c408f9d30c96097339d62f5d77 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 20:48:04 +0800 Subject: [PATCH 08/90] =?UTF-8?q?chore:=20=E7=BF=BB=E8=AF=91=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index d47f4cf96..939cb49ca 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -11,6 +11,7 @@ jobs: # ── 分析节点:Python 只运行一次,输出所有原子化标志 ── analyze: + name: 分析 Issue 快照链接 runs-on: ubuntu-latest outputs: has_snapshot: ${{ steps.analyze.outputs.has_snapshot }} @@ -47,6 +48,7 @@ jobs: # ── 处理分支一:缺失快照 → 打标签 + 评论 + 关闭 ── handle-missing-snapshot: + name: 处理缺失快照 needs: analyze if: needs.analyze.outputs.has_snapshot == 'false' runs-on: ubuntu-latest @@ -82,6 +84,7 @@ jobs: # ── 处理分支二:本地链接 → 打标签 + 评论(不关闭)── handle-local-link: + name: 处理本地链接 needs: analyze if: > needs.analyze.outputs.has_local_link == 'true' && @@ -113,6 +116,7 @@ jobs: # ── 处理分支三:不可访问快照 → 打标签 + 评论(不关闭)── handle-unreachable-snapshot: + name: 处理不可访问快照 needs: analyze if: > needs.analyze.outputs.has_unreachable == 'true' && @@ -144,6 +148,7 @@ jobs: # ── 处理分支四:链接不可访问(404)→ 打标签 + 评论 + 关闭 ── handle-network-404: + name: 处理链接不可访问(404) needs: analyze if: needs.analyze.outputs.network_status == '404' runs-on: ubuntu-latest @@ -179,6 +184,7 @@ jobs: # ── 处理分支五:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── handle-network-uncertain: + name: 处理网络不确定(403/5xx) needs: analyze if: needs.analyze.outputs.network_status == 'uncertain' runs-on: ubuntu-latest @@ -208,6 +214,7 @@ jobs: # ── 处理分支六:链接转换 + Bot 评论(检查通过)── handle-convert: + name: 处理链接转换 needs: analyze if: needs.analyze.outputs.has_convertible == 'true' runs-on: ubuntu-latest @@ -231,6 +238,7 @@ jobs: # ── 处理分支七:编辑恢复(移除旧标签 + 重新打开 Issue)── handle-recovery: + name: 处理编辑后恢复issue状态 needs: analyze if: needs.analyze.outputs.warning_type == 'recovery' runs-on: ubuntu-latest From dfa1dd1cb63266501702c43c8068b5acbd22e679 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 21:16:41 +0800 Subject: [PATCH 09/90] =?UTF-8?q?fix:=20handler=20Job=20=E7=BC=BA=E5=B0=91?= =?UTF-8?q?=20git=20=E4=BB=93=E5=BA=93=E4=B8=8A=E4=B8=8B=E6=96=87=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=20gh=20CLI=20=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 939cb49ca..666f9510f 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -5,8 +5,12 @@ on: types: [opened, edited] permissions: + contents: read issues: write +env: + GH_REPO: ${{ github.repository }} + jobs: # ── 分析节点:Python 只运行一次,输出所有原子化标志 ── From 7f571b4a5ee691011409876e196d470ac4a5e5d2 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 21:17:07 +0800 Subject: [PATCH 10/90] =?UTF-8?q?fix:=20=E9=A2=84=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=89=80=E9=9C=80=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20--add-label=20=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 666f9510f..25d50716f 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -41,6 +41,15 @@ jobs: with: python-version: '3.12' + - name: 创建工作流所需标签 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "缺失快照(missing-snapshot)" --color "e74c3c" --description "Issue缺少快照链接" --force + gh label create "本地链接(local-link)" --color "e67e22" --description "Issue包含本地链接" --force + gh label create "需补充链接(need-supplement-link)" --color "f39c12" --description "Issue包含不可访问的快照链接" --force + gh label create "链接无法访问(inaccessible-link)" --color "c0392b" --description "Issue中的链接无法访问" --force + - name: 分析 Issue 快照链接 id: analyze env: From e870bbb0ffc62aa6a025ee786bbeac37100e5313 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 21:17:34 +0800 Subject: [PATCH 11/90] =?UTF-8?q?fix:=20recovery=20=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E5=A4=9A=E6=9D=A1=E8=AD=A6=E5=91=8A=E8=AF=84=E8=AE=BA=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 25d50716f..2941133aa 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -290,3 +290,14 @@ jobs: issue-number: ${{ github.event.issue.number }} body: ${{ needs.analyze.outputs.comment_recovery }} edit-mode: replace + + - name: 清理残留警告评论 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + comment_ids=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | test("gkd-warning")) | select(.body | test("gkd-warning-recovery") | not) | .id') + for id in $comment_ids; do + gh api "repos/$GH_REPO/issues/comments/$id" -X DELETE 2>/dev/null || true + done From 3126148ec0ee08e557a6b3a8cc724ebd357b448b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 21:18:53 +0800 Subject: [PATCH 12/90] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=A7=84=E5=88=99=E5=8F=8D=E6=98=A0=E6=9D=83=E9=99=90?= =?UTF-8?q?/GH=5FREPO/=E6=A0=87=E7=AD=BE=E9=A2=84=E5=88=9B=E5=BB=BA/Recove?= =?UTF-8?q?ry=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/project_rules.md | 53 +++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 0b10bca68..41322ba17 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -20,13 +20,14 @@ Python (scripts/python/) = Worker(分析器) ### GitHub Actions (.yml) 职责 -- Workflow 触发与权限声明 +- Workflow 触发与权限声明(`contents: read` + `issues: write`) - Job / Step 编排与条件分支(if) -- 环境准备(checkout、setup-python) -- 标签操作(gh CLI) +- 环境准备(checkout、setup-python、标签预创建) +- 标签操作(gh CLI,标签在 analyze Job 中预创建) - 评论操作(find-comment + create-or-update-comment) - Issue 关闭 / 重新打开(gh CLI) - 读取 Python 输出,决定执行哪些 Job +- Recovery 场景清理残留警告评论(gh api DELETE) **原则:GitHub Actions 能完成的事,不允许放进 Python。** @@ -126,16 +127,16 @@ Python (scripts/python/) = Worker(分析器) ### Job 划分方案 -| Job 名称 | 触发条件 | 动作 | -| ----------------------------- | ------------------------------------------------------ | -------------------------- | -| `analyze` | 始终执行 | 运行 Python 分析 | -| `handle-missing-snapshot` | `has_snapshot == 'false'` | 标签 + 评论 + 关闭 | -| `handle-local-link` | `has_local_link == 'true' && network_status != '404'` | 标签 + 评论(不关闭) | -| `handle-unreachable-snapshot` | `has_unreachable == 'true' && network_status != '404'` | 标签 + 评论(不关闭) | -| `handle-network-404` | `network_status == '404'` | 标签 + 评论 + 关闭 | -| `handle-network-uncertain` | `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭) | -| `handle-convert` | `has_convertible == 'true'` | Bot 评论 | -| `handle-recovery` | `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 | +| Job 名称 | 触发条件 | 动作 | +| ----------------------------- | ------------------------------------------------------ | ------------------------------------- | +| `analyze` | 始终执行 | 运行 Python 分析 + 预创建标签 | +| `handle-missing-snapshot` | `has_snapshot == 'false'` | 标签 + 评论 + 关闭 | +| `handle-local-link` | `has_local_link == 'true' && network_status != '404'` | 标签 + 评论(不关闭) | +| `handle-unreachable-snapshot` | `has_unreachable == 'true' && network_status != '404'` | 标签 + 评论(不关闭) | +| `handle-network-404` | `network_status == '404'` | 标签 + 评论 + 关闭 | +| `handle-network-uncertain` | `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭) | +| `handle-convert` | `has_convertible == 'true'` | Bot 评论 | +| `handle-recovery` | `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | ### Job 互斥设计 @@ -181,6 +182,7 @@ Python 脚本只在 `analyze` Job 中执行一次,输出所有原子化布尔 - `` — Bot 转换评论 恢复场景使用 ` 标记) - - comment_local : 本地链接评论(含 标记) - comment_unreachable : 不可访问快照评论(含 标记) - comment_404 : 链接404评论(含 标记) - comment_uncertain : 网络不确定评论(含 标记) @@ -33,11 +32,14 @@ from dataclasses import dataclass from extractor import extract_links -from checker import check_local_links, check_unreachable_links, check_network_links +from checker import ( + check_unreachable_links, + check_network_links, + gkd_to_gh_attachment_url, +) from converter import convert_github_attachments from formatter import ( build_warning_missing, - build_warning_local, build_warning_unreachable, build_warning_inaccessible, build_warning_uncertain, @@ -49,7 +51,7 @@ # ── 快照相关链接类型集合 ── -_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot", "local"} +_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} # ── 网络检查聚合结果 ── @@ -75,16 +77,13 @@ def main(): issue_user = os.environ.get("ISSUE_USER", "") issue_action = os.environ.get("ISSUE_ACTION", "") - # 初始化所有输出变量 has_snapshot = "true" - has_local_link = "false" has_unreachable = "false" network_status = "skipped" network_detail = "" has_convertible = "false" warning_type = "" comment_missing = "" - comment_local = "" comment_unreachable = "" comment_404 = "" comment_uncertain = "" @@ -94,21 +93,19 @@ def main(): # ── 第一步:提取所有链接 ── links = extract_links(body) - # ── 第二步:判断是否缺少快照(致命 → 提前返回) ── + # ── 第二步:判断是否缺少快照(唯一致命 → 提前返回) ── has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) if not has_any_snapshot: comment_missing = build_warning_missing(issue_user) _output( has_snapshot="false", - has_local_link="false", has_unreachable="false", network_status="skipped", network_detail="", has_convertible="false", warning_type="missing", comment_missing=comment_missing, - comment_local="", comment_unreachable="", comment_404="", comment_uncertain="", @@ -117,86 +114,57 @@ def main(): ) return - # ── 第三步:检查本地链接(非致命,不提前返回) ── - local_links = check_local_links(links) - has_local_link = "true" if local_links else "false" - if local_links: - comment_local = build_warning_local(issue_user) - - # ── 第四步:检查不可访问快照链接(非致命,继续后续检查) ── + # ── 第三步:检查不可访问快照链接(非致命,继续后续检查) ── unreachable_links = check_unreachable_links(links) has_unreachable = "true" if unreachable_links else "false" if unreachable_links: comment_unreachable = build_warning_unreachable(issue_user) - # ── 第五步:网络有效性检查(仅 GitHub 附件) ── - attachment_links = [lnk for lnk in links if lnk.kind == "github_attachment"] + # ── 第四步:网络有效性检查 ── + # GKD 分享链接先转换为 GH 附件 URL 再检查,GH 附件链接直接检查 + net_result = _check_all_links(links) + network_status = net_result.status + network_detail = net_result.detail + + if network_status == "404": + comment_404 = build_warning_inaccessible(issue_user, net_result.fail_url) + + if network_status == "uncertain": + comment_uncertain = build_warning_uncertain( + issue_user, + net_result.uncertain_url, + net_result.uncertain_code, + net_result.uncertain_detail, + ) + # ── 第五步:链接转换 + Bot 评论生成(仅当含有 GitHub 附件链接时) ── + attachment_links = [lnk for lnk in links if lnk.kind == "github_attachment"] if attachment_links: - net_result = _check_attachments(attachment_links) - network_status = net_result.status - network_detail = net_result.detail - - # 404 是致命错误 → 提前返回 - if network_status == "404": - comment_404 = build_warning_inaccessible(issue_user, net_result.fail_url) - _output( - has_snapshot=has_snapshot, - has_local_link=has_local_link, - has_unreachable=has_unreachable, - network_status=network_status, - network_detail=network_detail, - has_convertible="false", - warning_type="inaccessible", - comment_missing=comment_missing, - comment_local=comment_local, - comment_unreachable=comment_unreachable, - comment_404=comment_404, - comment_uncertain="", - comment_recovery="", - comment_bot="", - ) - return - - # 不确定状态 → 生成评论,但不中断流程 - if network_status == "uncertain": - comment_uncertain = build_warning_uncertain( - issue_user, - net_result.uncertain_url, - net_result.uncertain_code, - net_result.uncertain_detail, - ) - - # ── 第六步:链接转换 + Bot 评论生成(仅当网络检查通过时) ── - if network_status in ("ok", "skipped") and attachment_links: converted = convert_github_attachments(attachment_links) has_convertible = "true" if converted else "false" if converted: comment_body = build_bot_comment(converted) comment_bot = "\n" + comment_body - # ── 第七步:编辑恢复判断 ── - # 当 edited 触发且所有检查均通过时,触发恢复流程 + # ── 第六步:编辑/评论恢复判断 ── + # 当 edited 或 issue_comment 触发且所有检查均通过时,触发恢复流程 all_clean = ( has_unreachable == "false" and network_status in ("ok", "skipped") - and has_local_link == "false" ) - if issue_action == "edited" and all_clean: + if issue_action in ("edited", "comment") and all_clean: warning_type = "recovery" comment_recovery = build_recovery_comment(issue_user) _output( has_snapshot=has_snapshot, - has_local_link=has_local_link, has_unreachable=has_unreachable, network_status=network_status, network_detail=network_detail, has_convertible=has_convertible, warning_type=warning_type, comment_missing=comment_missing, - comment_local=comment_local, comment_unreachable=comment_unreachable, comment_404=comment_404, comment_uncertain=comment_uncertain, @@ -205,19 +173,32 @@ def main(): ) -def _check_attachments(attachment_links: list) -> _NetworkCheckResult: +def _check_all_links(links: list) -> _NetworkCheckResult: """ - 对 GitHub 附件链接执行网络检查。 + 对所有可检查链接执行网络有效性检查。 + + 检查对象: + - GitHub 附件链接:直接检查原始 URL + - GKD 分享链接:先转换为 GH 附件 URL 再检查 - 遵循 Fail Fast 原则:遇到 404 立即返回致命结果。 + 遵循 Fail Fast 原则:遇到 404 立即返回。 不确定结果(403/5xx)为非致命,记录但不中断。 返回:_NetworkCheckResult 聚合结果 """ result = _NetworkCheckResult() - for lnk in attachment_links: - check = check_network_links(lnk.url) + for lnk in links: + if lnk.kind == "github_attachment": + check_url = lnk.url + elif lnk.kind == "gkd": + check_url = gkd_to_gh_attachment_url(lnk.url) + if not check_url: + continue + else: + continue + + check = check_network_links(check_url) if check.status == "404": result.status = "404" From a952cf4e06734543f62af96c1df93d48b7f69d3a Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 22:57:58 +0800 Subject: [PATCH 16/90] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20build=5F?= =?UTF-8?q?warning=5Flocal,=20404=20=E4=B8=8D=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/formatter.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index c9cbea28d..af31dc459 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -2,8 +2,8 @@ 评论格式化模块 负责生成所有 Bot 评论的 Markdown 内容,包括: -- 各类警告评论(缺失快照 / 本地链接 / 不可访问快照 / 链接无法访问 / 不确定) -- 编辑恢复评论 +- 各类警告评论(缺失快照 / 不可访问快照 / 链接无法访问 / 不确定) +- 编辑/评论恢复评论 - 快照转换 Bot 评论(按 App > Activity 分组) 每类评论使用独立的 HTML 标记,供 YAML 工作流中 find-comment 按场景查找。 @@ -17,7 +17,7 @@ def build_warning_missing(user: str) -> str: - """缺失快照时的警告评论""" + """缺失快照时的警告评论(关闭 Issue)""" return ( "\n" f"您好 @{user},由于您没有提供快照链接,此 Issue 已被自动关闭。\n\n" @@ -25,16 +25,6 @@ def build_warning_missing(user: str) -> str: ) -def build_warning_local(user: str) -> str: - """检测到本地链接时的警告评论(不关闭 Issue)""" - return ( - "\n" - f"您好 @{user},检测到您使用了不可分享的本地链接" - "(如 localhost、127.0.0.1、file:// 等),他人无法访问该链接。\n\n" - "请使用正确的分享方式上传快照。" - ) - - def build_warning_unreachable(user: str) -> str: """检测到 i.gkd.li/snapshot/ 时的提醒评论(不关闭)""" return ( @@ -47,12 +37,12 @@ def build_warning_unreachable(user: str) -> str: def build_warning_inaccessible(user: str, url: str) -> str: - """链接不可访问(404)时的警告评论""" + """链接不可访问(404)时的警告评论(不关闭 Issue)""" return ( "\n" f"您好 @{user},检测到您提供的快照链接无法访问:\n\n" f"`{url}`\n\n" - "此 Issue 已被自动关闭。请确认链接正确后重新提交。" + "请确认链接正确后在评论区补充有效的快照链接。" ) @@ -70,7 +60,7 @@ def build_warning_uncertain( def build_recovery_comment(user: str) -> str: - """编辑修正后检查通过时的恢复评论""" + """编辑/评论补充有效链接后检查通过时的恢复评论""" return ( "\n" f"✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" From 303c87b0dc21443fde0f747beb1e47f5a0674350 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 22:58:07 +0800 Subject: [PATCH 17/90] =?UTF-8?q?refactor:=20utils=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E9=93=BE=E6=8E=A5=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/python/utils.py b/scripts/python/utils.py index 65c34cdc8..93e116c1e 100644 --- a/scripts/python/utils.py +++ b/scripts/python/utils.py @@ -12,7 +12,6 @@ MANAGED_LABELS = [ "缺失快照(missing-snapshot)", - "本地链接(local-link)", "需补充链接(need-supplement-link)", "链接无法访问(inaccessible-link)", ] From 51559d69645628b097cc395cf2b1811dc8b01e5e Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 22:58:18 +0800 Subject: [PATCH 18/90] =?UTF-8?q?refactor:=20YAML=20=E6=96=B0=E5=A2=9E=20i?= =?UTF-8?q?ssue=5Fcomment,=20=E7=A7=BB=E9=99=A4=20local=20Job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 68 +++++------------------ 1 file changed, 15 insertions(+), 53 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 2941133aa..8154157d5 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -3,6 +3,8 @@ name: issue_content_check on: issues: types: [opened, edited] + issue_comment: + types: [created] permissions: contents: read @@ -17,16 +19,18 @@ jobs: analyze: name: 分析 Issue 快照链接 runs-on: ubuntu-latest + # issue_comment 事件仅处理 Issue 作者本人的评论 + if: > + github.event_name != 'issue_comment' || + github.event.comment.user.login == github.event.issue.user.login outputs: has_snapshot: ${{ steps.analyze.outputs.has_snapshot }} - has_local_link: ${{ steps.analyze.outputs.has_local_link }} has_unreachable: ${{ steps.analyze.outputs.has_unreachable }} network_status: ${{ steps.analyze.outputs.network_status }} network_detail: ${{ steps.analyze.outputs.network_detail }} has_convertible: ${{ steps.analyze.outputs.has_convertible }} warning_type: ${{ steps.analyze.outputs.warning_type }} comment_missing: ${{ steps.analyze.outputs.comment_missing }} - comment_local: ${{ steps.analyze.outputs.comment_local }} comment_unreachable: ${{ steps.analyze.outputs.comment_unreachable }} comment_404: ${{ steps.analyze.outputs.comment_404 }} comment_uncertain: ${{ steps.analyze.outputs.comment_uncertain }} @@ -46,7 +50,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh label create "缺失快照(missing-snapshot)" --color "e74c3c" --description "Issue缺少快照链接" --force - gh label create "本地链接(local-link)" --color "e67e22" --description "Issue包含本地链接" --force gh label create "需补充链接(need-supplement-link)" --color "f39c12" --description "Issue包含不可访问的快照链接" --force gh label create "链接无法访问(inaccessible-link)" --color "c0392b" --description "Issue中的链接无法访问" --force @@ -55,7 +58,7 @@ jobs: env: ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_USER: ${{ github.event.issue.user.login }} - ISSUE_ACTION: ${{ github.event.action }} + ISSUE_ACTION: ${{ github.event_name == 'issue_comment' && 'comment' || github.event.action }} run: python3 scripts/python/check_issue.py # ── 处理分支一:缺失快照 → 打标签 + 评论 + 关闭 ── @@ -94,46 +97,12 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - # ── 处理分支二:本地链接 → 打标签 + 评论(不关闭)── - - handle-local-link: - name: 处理本地链接 - needs: analyze - if: > - needs.analyze.outputs.has_local_link == 'true' && - needs.analyze.outputs.network_status != '404' - runs-on: ubuntu-latest - steps: - - name: 打标签「本地链接」 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue edit "$ISSUE_NUMBER" --add-label "本地链接(local-link)" - - - name: 查找已有警告评论 - id: find-warning - uses: peter-evans/find-comment@v4 - with: - issue-number: ${{ github.event.issue.number }} - comment-author: 'github-actions[bot]' - body-includes: '' - - - name: 发布本地链接警告评论 - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.find-warning.outputs.comment-id }} - issue-number: ${{ github.event.issue.number }} - body: ${{ needs.analyze.outputs.comment_local }} - edit-mode: replace - - # ── 处理分支三:不可访问快照 → 打标签 + 评论(不关闭)── + # ── 处理分支二:不可访问快照 → 打标签 + 评论(不关闭)── handle-unreachable-snapshot: name: 处理不可访问快照 needs: analyze - if: > - needs.analyze.outputs.has_unreachable == 'true' && - needs.analyze.outputs.network_status != '404' + if: needs.analyze.outputs.has_unreachable == 'true' runs-on: ubuntu-latest steps: - name: 打标签「需补充链接」 @@ -158,7 +127,7 @@ jobs: body: ${{ needs.analyze.outputs.comment_unreachable }} edit-mode: replace - # ── 处理分支四:链接不可访问(404)→ 打标签 + 评论 + 关闭 ── + # ── 处理分支三:链接不可访问(404)→ 打标签 + 评论(不关闭)── handle-network-404: name: 处理链接不可访问(404) @@ -180,7 +149,7 @@ jobs: comment-author: 'github-actions[bot]' body-includes: '' - - name: 发布链接不可访问警告评论 + - name: 发布链接不可访问提醒评论 uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} @@ -188,13 +157,7 @@ jobs: body: ${{ needs.analyze.outputs.comment_404 }} edit-mode: replace - - name: 关闭 Issue(链接不可访问) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - - # ── 处理分支五:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── + # ── 处理分支四:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── handle-network-uncertain: name: 处理网络不确定(403/5xx) @@ -224,7 +187,7 @@ jobs: body: ${{ needs.analyze.outputs.comment_uncertain }} edit-mode: replace - # ── 处理分支六:链接转换 + Bot 评论(检查通过)── + # ── 处理分支五:链接转换 + Bot 评论(含有 GitHub 附件链接时)── handle-convert: name: 处理链接转换 @@ -248,10 +211,10 @@ jobs: body: ${{ needs.analyze.outputs.comment_bot }} edit-mode: replace - # ── 处理分支七:编辑恢复(移除旧标签 + 重新打开 Issue)── + # ── 处理分支六:编辑/评论恢复(移除旧标签 + 重新打开 Issue)── handle-recovery: - name: 处理编辑后恢复issue状态 + name: 处理编辑/评论后恢复issue状态 needs: analyze if: needs.analyze.outputs.warning_type == 'recovery' runs-on: ubuntu-latest @@ -263,7 +226,6 @@ jobs: run: | for label in \ "缺失快照(missing-snapshot)" \ - "本地链接(local-link)" \ "需补充链接(need-supplement-link)" \ "链接无法访问(inaccessible-link)"; do gh issue edit "$ISSUE_NUMBER" --remove-label "$label" 2>/dev/null || true From efaf1695ccec6950308e2a69dc9d373ad592cb47 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 22:58:26 +0800 Subject: [PATCH 19/90] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20project=5Frules.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/project_rules.md | 217 ++++++++++++++++++++--------------- 1 file changed, 124 insertions(+), 93 deletions(-) diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 41322ba17..cb96d892f 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -36,7 +36,8 @@ Python (scripts/python/) = Worker(分析器) - Markdown 文本解析与正则匹配 - URL 提取与分类 - HTTP 网络请求(HEAD / GET+Range) -- 数据转换(GitHub 附件 → GKD 代理链接) +- GKD 分享链接 → GH 附件 URL 转换(用于网络检查) +- GitHub 附件 → GKD 代理链接转换(用于 Bot 评论) - Markdown 评论内容生成 - 结果输出到 GITHUB_OUTPUT @@ -52,97 +53,108 @@ Python (scripts/python/) = Worker(分析器) ## 工作流业务流程(多 Job 架构) ``` -接收到 Issue (opened / edited) +接收到 Issue (opened / edited) 或 Issue 作者评论 (issue_comment) │ ▼ ┌─────────────────────────────────┐ │ analyze Job │ │ 环境准备 + Python 分析(一次) │ │ 输出原子化标志到 GITHUB_OUTPUT │ +│ (issue_comment 仅处理作者评论) │ └──────────────┬──────────────────┘ │ - ┌──────────┼──────────────────────────────┐ - │ │ │ - ▼ ▼ ▼ - has_snapshot has_local_link network_status - == 'false' == 'true' == '404' - │ && status!='404' │ - ▼ ▼ ▼ -┌─────────┐ ┌──────────┐ ┌──────────────┐ -│ handle- │ │ handle- │ │ handle- │ -│ missing │ │ local- │ │ network-404 │ -│ snapshot│ │ link │ │ 标签+评论+关闭│ -│标签+评论│ │标签+评论 │ └──────────────┘ -│ +关闭 │ │(不关闭) │ -└─────────┘ └──────────┘ + ▼ + has_snapshot == 'false' │ ▼ - has_unreachable == 'true' - && status != '404' + ┌──────────────┐ + │ handle- │ + │ missing- │ + │ snapshot │ + │ 标签+评论 │ + │ +关闭 │ + └──────────────┘ + + │ has_snapshot == 'true' + ▼ + has_unreachable == 'true' │ ▼ - ┌──────────────┐ - │ handle- │ - │ unreachable- │ - │ snapshot │ - │ 标签+评论 │ - │ (不关闭) │ - └──────────────┘ + ┌──────────────┐ + │ handle- │ + │ unreachable- │ + │ snapshot │ + │ 标签+评论 │ + │ (不关闭) │ + └──────────────┘ + │ ▼ - network_status == 'uncertain' + network_status == '404' │ ▼ - ┌──────────────┐ - │ handle- │ - │ network- │ - │ uncertain │ - │ 标签+折叠评论│ - │ (不关闭) │ - └──────────────┘ + ┌──────────────┐ + │ handle- │ + │ network-404 │ + │ 标签+评论 │ + │ (不关闭) │ + └──────────────┘ + │ - ▼ 可访问 - has_convertible == 'true' + ▼ + network_status == 'uncertain' │ ▼ - ┌──────────────┐ - │ handle- │ - │ convert │ - │ Bot 评论 │ - └──────────────┘ + ┌──────────────┐ + │ handle- │ + │ network- │ + │ uncertain │ + │ 标签+折叠评论│ + │ (不关闭) │ + └──────────────┘ + + │ + ▼ 含有 GitHub 附件链接 + has_convertible == 'true' │ ▼ - warning_type == 'recovery' - (edited + 全部检查通过) + ┌──────────────┐ + │ handle- │ + │ convert │ + │ Bot 评论 │ + └──────────────┘ + + │ + ▼ edited/comment + 全部检查通过 + warning_type == 'recovery' │ ▼ - ┌──────────────┐ - │ handle- │ - │ recovery │ - │ 移除标签 │ - │ 重新打开 │ - │ 恢复评论 │ - └──────────────┘ + ┌──────────────┐ + │ handle- │ + │ recovery │ + │ 移除标签 │ + │ 重新打开 │ + │ 恢复评论 │ + │ 清理残留 │ + └──────────────┘ ``` ### Job 划分方案 -| Job 名称 | 触发条件 | 动作 | -| ----------------------------- | ------------------------------------------------------ | ------------------------------------- | -| `analyze` | 始终执行 | 运行 Python 分析 + 预创建标签 | -| `handle-missing-snapshot` | `has_snapshot == 'false'` | 标签 + 评论 + 关闭 | -| `handle-local-link` | `has_local_link == 'true' && network_status != '404'` | 标签 + 评论(不关闭) | -| `handle-unreachable-snapshot` | `has_unreachable == 'true' && network_status != '404'` | 标签 + 评论(不关闭) | -| `handle-network-404` | `network_status == '404'` | 标签 + 评论 + 关闭 | -| `handle-network-uncertain` | `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭) | -| `handle-convert` | `has_convertible == 'true'` | Bot 评论 | -| `handle-recovery` | `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | +| Job 名称 | 触发条件 | 动作 | +| ----------------------------- | ---------------------------------------- | ------------------------------------- | +| `analyze` | 始终执行(issue_comment 仅处理作者评论) | 运行 Python 分析 + 预创建标签 | +| `handle-missing-snapshot` | `has_snapshot == 'false'` | 标签 + 评论 + 关闭 | +| `handle-unreachable-snapshot` | `has_unreachable == 'true'` | 标签 + 评论(不关闭) | +| `handle-network-404` | `network_status == '404'` | 标签 + 评论(不关闭) | +| `handle-network-uncertain` | `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭) | +| `handle-convert` | `has_convertible == 'true'` | Bot 评论 | +| `handle-recovery` | `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | -### Job 互斥设计 +### 多种警告可共存 -- `handle-local-link` 和 `handle-unreachable-snapshot` 添加 `network_status != '404'` 条件 -- 当 404 发生时,高优先级的 `handle-network-404` 抑制低优先级的非致命场景 -- 避免同一 Issue 上同时出现语义冲突的标签和评论 +不可访问快照和 404/不确定可以同时触发,各自打标签和发评论。 +只有缺失快照是唯一致命场景(关闭 Issue)。 --- @@ -157,13 +169,13 @@ Python 脚本只在 `analyze` Job 中执行一次,输出所有原子化布尔 ### 2. Fail Fast 原则 -网络检查遇到第一个致命错误(404)立即停止,不发后续请求。 +网络检查遇到第一个 404 立即停止,不发后续请求。 **原因:** 节省网络请求和运行时间,审核类工作流不需要完整报告。 ### 3. 幂等性(Idempotent) -每次 opened 或 edited 触发都完全重跑全流程,保证最终状态一致。 +每次 opened / edited / issue_comment 触发都完全重跑全流程,保证最终状态一致。 **原因:** 避免遗留旧标签或旧评论,行为可预测。 @@ -174,11 +186,10 @@ Python 脚本只在 `analyze` Job 中执行一次,输出所有原子化布尔 每个场景使用独立的 HTML 标记: - `` — 缺失快照 -- `` — 本地链接 - `` — 不可访问快照 - `` — 链接 404 - `` — 网络不确定 -- `` — 编辑恢复 +- `` — 编辑/评论恢复 - `` — Bot 转换评论 恢复场景使用 `` 标记) | -| `comment_local` | string | 本地链接评论 Markdown(含 `` 标记) | -| `comment_unreachable` | string | 不可访问快照评论 Markdown(含 `` 标记) | -| `comment_404` | string | 链接 404 评论 Markdown(含 `` 标记) | -| `comment_uncertain` | string | 网络不确定评论 Markdown(含 `` 标记) | -| `comment_recovery` | string | 恢复评论 Markdown(含 `` 标记) | -| `comment_bot` | string | Bot 评论 Markdown(含 `` 标记) | +| 变量名 | 类型 | 含义 | +| --------------------- | ------ | -------------------------------------------------------------------------------------- | +| `has_snapshot` | bool | 是否包含任何快照链接 | +| `has_unreachable` | bool | 是否包含不可访问快照 | +| `network_status` | string | 网络检查结果:`ok` / `404` / `uncertain` / `skipped` | +| `network_detail` | string | 网络错误详情(折叠展示用) | +| `has_convertible` | bool | 是否有可转换的 GitHub 附件 | +| `warning_type` | string | 警告类型:`missing` / `unreachable` / `inaccessible` / `uncertain` / `recovery` / `""` | +| `comment_missing` | string | 缺失快照评论 Markdown(含 `` 标记) | +| `comment_unreachable` | string | 不可访问快照评论 Markdown(含 `` 标记) | +| `comment_404` | string | 链接 404 评论 Markdown(含 `` 标记) | +| `comment_uncertain` | string | 网络不确定评论 Markdown(含 `` 标记) | +| `comment_recovery` | string | 恢复评论 Markdown(含 `` 标记) | +| `comment_bot` | string | Bot 评论 Markdown(含 `` 标记) | --- ## 标签定义 -| 场景 | 标签名 | 是否关闭 Issue | -| ------------------------- | ---------------------------------- | ---------------------- | -| 缺失快照 | `缺失快照(missing-snapshot)` | ✅ 关闭(not planned) | -| 本地链接 | `本地链接(local-link)` | ❌ 不关闭 | -| 不可访问快照链接 | `需补充链接(need-supplement-link)` | ❌ 不关闭 | -| 链接无法访问(404/403/5xx) | `链接无法访问(inaccessible-link)` | 404关闭,403/5xx不关闭 | +| 场景 | 标签名 | 是否关闭 Issue | +| ------------------------- | ---------------------------------- | --------------------- | +| 缺失快照 | `缺失快照(missing-snapshot)` | ✅ 关闭(not planned) | +| 不可访问快照链接 | `需补充链接(need-supplement-link)` | ❌ 不关闭 | +| 链接无法访问(404/403/5xx) | `链接无法访问(inaccessible-link)` | ❌ 不关闭 | --- @@ -257,17 +280,24 @@ Workflow 级设置 `GH_REPO: ${{ github.repository }}`,使 handler Job 中的 | ------------ | ----------------------------------------------- | ---------------------- | | GKD 分享链接 | `https://i.gkd.li/i/\d+` | `gkd` | | GitHub 附件 | `https://github.com/user-attachments/files/...` | `github_attachment` | -| 本地链接 | `localhost` / `127.0.0.1` / `file://` | `local` | | 不可访问快照 | `https://i.gkd.li/snapshot/...` | `unreachable_snapshot` | --- ## 链接转换规则 +### Bot 评论转换(GitHub 附件 → GKD 代理链接) + - 仅转换 `github_attachment` 类型链接 - 转换公式:`https://i.gkd.li/i?url={{原始GitHub附件URL}}` - GKD 链接原样保留,不转换 +### 网络检查转换(GKD 分享链接 → GH 附件 URL) + +- 仅用于网络可访问性检查,不影响 Bot 评论输出 +- 转换公式:`https://i.gkd.li/i/{id}` → `https://github.com/user-attachments/files/{id}/file.zip` +- `{id}` 为 GKD 链接中的数字部分,`file.zip` 为固定占位符 + --- ## Bot 评论格式 @@ -295,7 +325,7 @@ Workflow 级设置 `GH_REPO: ${{ github.repository }}`,使 handler Job 中的 scripts/python/ ├── check_issue.py # 主入口:协调各模块,输出分析结果 ├── extractor.py # 链接提取与分类 - ├── checker.py # 三类检查(本地/不可访问/网络) + ├── checker.py # 两类检查(不可访问快照/网络)+ GKD→GH 转换 ├── converter.py # GitHub 附件 → GKD 代理链接转换 ├── formatter.py # Bot 评论 Markdown 格式化生成 └── utils.py # 公共工具函数(GITHUB_OUTPUT 写入等) @@ -317,9 +347,10 @@ scripts/python/ 1. 优先 HEAD 请求(最快,只获取响应头) 2. HEAD 返回 405 时回退到 GET + Range 头(只请求前 1 字节) 3. 超时时间:20 秒 -4. 404 → 确认不可访问 +4. 404 → 确认不可访问(非致命,不关闭 Issue) 5. 403 / 5xx → 不确定,折叠展示错误详情 6. 3xx → 跟随重定向,以最终状态码为准 +7. GKD 分享链接先转换为 GH 附件 URL 再检查 --- From 88e16d15a24da166d2f9734bfd1bab1f81cb9013 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 23:11:00 +0800 Subject: [PATCH 20/90] =?UTF-8?q?fix:=20YAML=20=E7=BA=BF=E6=80=A7=E9=93=BE?= =?UTF-8?q?=E5=BC=8F=E4=BE=9D=E8=B5=96,=20=E7=BC=BA=E5=A4=B1=E9=98=BB?= =?UTF-8?q?=E6=96=AD=E5=90=8E=E7=BB=AD,=20404=E9=98=BB=E6=96=AD=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2,=20=E8=AF=84=E8=AE=BA=E5=86=85=E5=AE=B9=E4=BC=A0?= =?UTF-8?q?=E5=85=A5Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 64 +++++++++++++++++------ scripts/python/check_issue.py | 6 ++- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 8154157d5..87ba2ceea 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -57,11 +57,12 @@ jobs: id: analyze env: ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_COMMENT_BODY: ${{ github.event.comment.body || '' }} ISSUE_USER: ${{ github.event.issue.user.login }} ISSUE_ACTION: ${{ github.event_name == 'issue_comment' && 'comment' || github.event.action }} run: python3 scripts/python/check_issue.py - # ── 处理分支一:缺失快照 → 打标签 + 评论 + 关闭 ── + # ── 线性步骤一:缺失快照 → 打标签 + 评论 + 关闭(致命,阻断后续所有 Job) ── handle-missing-snapshot: name: 处理缺失快照 @@ -97,12 +98,15 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - # ── 处理分支二:不可访问快照 → 打标签 + 评论(不关闭)── + # ── 线性步骤二:不可访问快照 → 打标签 + 评论(非致命,不阻断后续) ── handle-unreachable-snapshot: name: 处理不可访问快照 - needs: analyze - if: needs.analyze.outputs.has_unreachable == 'true' + needs: [analyze, handle-missing-snapshot] + if: >- + always() && + needs.handle-missing-snapshot.result == 'skipped' && + needs.analyze.outputs.has_unreachable == 'true' runs-on: ubuntu-latest steps: - name: 打标签「需补充链接」 @@ -127,12 +131,15 @@ jobs: body: ${{ needs.analyze.outputs.comment_unreachable }} edit-mode: replace - # ── 处理分支三:链接不可访问(404)→ 打标签 + 评论(不关闭)── + # ── 分支步骤三:链接不可访问(404)→ 打标签 + 评论(非致命,阻断转换) ── handle-network-404: name: 处理链接不可访问(404) - needs: analyze - if: needs.analyze.outputs.network_status == '404' + needs: [analyze, handle-missing-snapshot] + if: >- + always() && + needs.handle-missing-snapshot.result == 'skipped' && + needs.analyze.outputs.network_status == '404' runs-on: ubuntu-latest steps: - name: 打标签「链接无法访问」 @@ -157,12 +164,15 @@ jobs: body: ${{ needs.analyze.outputs.comment_404 }} edit-mode: replace - # ── 处理分支四:网络不确定(403/5xx)→ 打标签 + 折叠评论(不关闭)── + # ── 分支步骤三:网络不确定(403/5xx)→ 打标签 + 折叠评论(非致命,阻断转换) ── handle-network-uncertain: name: 处理网络不确定(403/5xx) - needs: analyze - if: needs.analyze.outputs.network_status == 'uncertain' + needs: [analyze, handle-missing-snapshot] + if: >- + always() && + needs.handle-missing-snapshot.result == 'skipped' && + needs.analyze.outputs.network_status == 'uncertain' runs-on: ubuntu-latest steps: - name: 打标签「链接无法访问」(不确定) @@ -187,12 +197,23 @@ jobs: body: ${{ needs.analyze.outputs.comment_uncertain }} edit-mode: replace - # ── 处理分支五:链接转换 + Bot 评论(含有 GitHub 附件链接时)── + # ── 步骤四:链接转换 + Bot 评论(仅当缺失未触发 + 网络可访问时) ── handle-convert: name: 处理链接转换 - needs: analyze - if: needs.analyze.outputs.has_convertible == 'true' + needs: + [ + analyze, + handle-missing-snapshot, + handle-network-404, + handle-network-uncertain, + ] + if: >- + always() && + needs.handle-missing-snapshot.result == 'skipped' && + needs.handle-network-404.result == 'skipped' && + needs.handle-network-uncertain.result == 'skipped' && + needs.analyze.outputs.has_convertible == 'true' runs-on: ubuntu-latest steps: - name: 查找已有 Bot 评论 @@ -211,12 +232,23 @@ jobs: body: ${{ needs.analyze.outputs.comment_bot }} edit-mode: replace - # ── 处理分支六:编辑/评论恢复(移除旧标签 + 重新打开 Issue)── + # ── 步骤五:编辑/评论恢复(仅当缺失未触发 + 所有检查通过时) ── handle-recovery: name: 处理编辑/评论后恢复issue状态 - needs: analyze - if: needs.analyze.outputs.warning_type == 'recovery' + needs: + [ + analyze, + handle-missing-snapshot, + handle-unreachable-snapshot, + handle-network-404, + handle-network-uncertain, + handle-convert, + ] + if: >- + always() && + needs.handle-missing-snapshot.result == 'skipped' && + needs.analyze.outputs.warning_type == 'recovery' runs-on: ubuntu-latest steps: - name: 移除所有本工作流管理的标签 diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py index 581a67ba5..0b3074bef 100644 --- a/scripts/python/check_issue.py +++ b/scripts/python/check_issue.py @@ -74,9 +74,13 @@ class _NetworkCheckResult: def main(): body = os.environ.get("ISSUE_BODY", "") or "" + comment_body = os.environ.get("ISSUE_COMMENT_BODY", "") or "" issue_user = os.environ.get("ISSUE_USER", "") issue_action = os.environ.get("ISSUE_ACTION", "") + # 合并 Issue Body 和评论内容一起分析(评论补充的链接也参与检查) + full_text = body + "\n" + comment_body if comment_body else body + has_snapshot = "true" has_unreachable = "false" network_status = "skipped" @@ -91,7 +95,7 @@ def main(): comment_bot = "" # ── 第一步:提取所有链接 ── - links = extract_links(body) + links = extract_links(full_text) # ── 第二步:判断是否缺少快照(唯一致命 → 提前返回) ── has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) From edac4329137b627937bddf93c23c43e1713fdd51 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 23:12:08 +0800 Subject: [PATCH 21/90] =?UTF-8?q?docs:=20=E6=B5=81=E7=A8=8B=E5=9B=BE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=BC=A9=E8=BF=9B=E5=88=97=E8=A1=A8,=20?= =?UTF-8?q?=E9=81=BF=E5=85=8DASCII=E6=96=B9=E6=A1=86=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs: 更新流程图和 Job 表反映链式依赖 --- .trae/rules/project_rules.md | 124 +++++++++-------------------------- 1 file changed, 31 insertions(+), 93 deletions(-) diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index cb96d892f..6f265078a 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -53,103 +53,41 @@ Python (scripts/python/) = Worker(分析器) ## 工作流业务流程(多 Job 架构) ``` -接收到 Issue (opened / edited) 或 Issue 作者评论 (issue_comment) - │ - ▼ -┌─────────────────────────────────┐ -│ analyze Job │ -│ 环境准备 + Python 分析(一次) │ -│ 输出原子化标志到 GITHUB_OUTPUT │ -│ (issue_comment 仅处理作者评论) │ -└──────────────┬──────────────────┘ - │ - ▼ - has_snapshot == 'false' - │ - ▼ - ┌──────────────┐ - │ handle- │ - │ missing- │ - │ snapshot │ - │ 标签+评论 │ - │ +关闭 │ - └──────────────┘ - - │ has_snapshot == 'true' - ▼ - has_unreachable == 'true' - │ - ▼ - ┌──────────────┐ - │ handle- │ - │ unreachable- │ - │ snapshot │ - │ 标签+评论 │ - │ (不关闭) │ - └──────────────┘ - - │ - ▼ - network_status == '404' - │ - ▼ - ┌──────────────┐ - │ handle- │ - │ network-404 │ - │ 标签+评论 │ - │ (不关闭) │ - └──────────────┘ - - │ - ▼ - network_status == 'uncertain' - │ - ▼ - ┌──────────────┐ - │ handle- │ - │ network- │ - │ uncertain │ - │ 标签+折叠评论│ - │ (不关闭) │ - └──────────────┘ - - │ - ▼ 含有 GitHub 附件链接 - has_convertible == 'true' - │ - ▼ - ┌──────────────┐ - │ handle- │ - │ convert │ - │ Bot 评论 │ - └──────────────┘ - - │ - ▼ edited/comment + 全部检查通过 - warning_type == 'recovery' - │ - ▼ - ┌──────────────┐ - │ handle- │ - │ recovery │ - │ 移除标签 │ - │ 重新打开 │ - │ 恢复评论 │ - │ 清理残留 │ - └──────────────┘ +1. analyze + - 合并 Issue Body + 评论内容 + - issue_comment 仅处理作者评论 + | + +-- has_snapshot == 'false' + | └─> handle-missing-snapshot: 标签+评论+关闭 (阻断后续所有 Job) + | + +-- has_snapshot == 'true' + | + +-- has_unreachable == 'true' + | └─> handle-unreachable-snapshot: 标签+评论 (不关闭, 不阻断后续) + | + +-- network_status 分支 (并行): + | +-- '404' ──> handle-network-404: 标签+评论 (不关闭, 阻断转换) + | +-- 'uncertain' ──> handle-network-uncertain: 标签+评论 (不关闭, 阻断转换) + | +-- 'ok' ──> (无动作, 继续后续) + | + +-- has_convertible == 'true' (仅当 404/uncertain 均 skipped) + | └─> handle-convert: Bot 评论 + | + └─> warning_type == 'recovery' (仅当 missing-skipped + 全部检查通过) + └─> handle-recovery: 移除标签+重新打开+恢复评论+清理残留 ``` ### Job 划分方案 -| Job 名称 | 触发条件 | 动作 | -| ----------------------------- | ---------------------------------------- | ------------------------------------- | -| `analyze` | 始终执行(issue_comment 仅处理作者评论) | 运行 Python 分析 + 预创建标签 | -| `handle-missing-snapshot` | `has_snapshot == 'false'` | 标签 + 评论 + 关闭 | -| `handle-unreachable-snapshot` | `has_unreachable == 'true'` | 标签 + 评论(不关闭) | -| `handle-network-404` | `network_status == '404'` | 标签 + 评论(不关闭) | -| `handle-network-uncertain` | `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭) | -| `handle-convert` | `has_convertible == 'true'` | Bot 评论 | -| `handle-recovery` | `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | +| Job 名称 | 依赖 | 触发条件 | 动作 | +| ----------------------------- | ----------------------------------- | ----------------------------------------------------- | -------------------------------------- | +| `analyze` | 无 | 始终执行(issue_comment 仅处理作者评论) | 运行 Python 分析 + 预创建标签 | +| `handle-missing-snapshot` | analyze | `has_snapshot == 'false'` | 标签 + 评论 + 关闭(阻断后续所有 Job) | +| `handle-unreachable-snapshot` | analyze + handle-missing-snapshot | missing-skipped && `has_unreachable == 'true'` | 标签 + 评论(不关闭,不阻断后续) | +| `handle-network-404` | analyze + handle-missing-snapshot | missing-skipped && `network_status == '404'` | 标签 + 评论(不关闭,阻断转换) | +| `handle-network-uncertain` | analyze + handle-missing-snapshot | missing-skipped && `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭,阻断转换) | +| `handle-convert` | analyze + missing + 404 + uncertain | missing/404/uncertain 均 skipped && `has_convertible` | Bot 评论 | +| `handle-recovery` | analyze + 所有上述 Job | missing-skipped && `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | ### 多种警告可共存 From 46d0e745a76ba91aabeeefda16fcb0f4d493a911 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 23:33:14 +0800 Subject: [PATCH 22/90] chore: update lib --- .github/workflows/issue_content_check.yml | 4 ++-- .trae/rules/project_rules.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 87ba2ceea..b3bff8f1c 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -38,10 +38,10 @@ jobs: comment_bot: ${{ steps.analyze.outputs.comment_bot }} steps: - name: 检出代码仓库 - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 配置 Python 运行环境 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 6f265078a..3f38aa6b5 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -296,8 +296,8 @@ scripts/python/ | Action | 用途 | | ----------------------------------------- | --------------------------- | -| `actions/checkout@v4` | 拉取仓库代码 | -| `actions/setup-python@v5` | 初始化 Python 环境 | +| `actions/checkout@v7` | 拉取仓库代码 | +| `actions/setup-python@v6` | 初始化 Python 环境 | | `peter-evans/find-comment@v4` | 查找已有评论(按作者+内容) | | `peter-evans/create-or-update-comment@v5` | 发布/更新评论(防刷屏) | | `gh` CLI(内置) | 标签操作、关闭/打开 Issue | From 4c20dbe6ada7307cf87e1bf4e8ade238d8b2394e Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 23:36:05 +0800 Subject: [PATCH 23/90] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E4=B8=BA=E6=9C=89=E6=9C=89=E6=95=88=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=8D=B3=E5=8F=AF,=20=E4=B8=8D=E8=A6=81=E6=B1=82?= =?UTF-8?q?=E6=97=A7=E9=97=AE=E9=A2=98=E9=93=BE=E6=8E=A5=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 3 ++- scripts/python/check_issue.py | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index b3bff8f1c..27cd6532e 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -106,7 +106,8 @@ jobs: if: >- always() && needs.handle-missing-snapshot.result == 'skipped' && - needs.analyze.outputs.has_unreachable == 'true' + needs.analyze.outputs.has_unreachable == 'true' && + needs.analyze.outputs.warning_type != 'recovery' runs-on: ubuntu-latest steps: - name: 打标签「需补充链接」 diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py index 0b3074bef..73958060c 100644 --- a/scripts/python/check_issue.py +++ b/scripts/python/check_issue.py @@ -152,12 +152,11 @@ def main(): # ── 第六步:编辑/评论恢复判断 ── # 当 edited 或 issue_comment 触发且所有检查均通过时,触发恢复流程 - all_clean = ( - has_unreachable == "false" - and network_status in ("ok", "skipped") - ) + # 恢复条件:edited/comment + 至少有一个有效快照链接 + 网络OK + # 不要求旧问题链接消失——作者补充有效链接即可恢复 + has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in links) - if issue_action in ("edited", "comment") and all_clean: + if issue_action in ("edited", "comment") and has_valid_snapshot and network_status in ("ok", "skipped"): warning_type = "recovery" comment_recovery = build_recovery_comment(issue_user) From 187da85cda8ad38b5786309c0f381e30dcdf0c11 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 9 Jul 2026 23:52:51 +0800 Subject: [PATCH 24/90] =?UTF-8?q?perf:=20=E9=A2=84=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E6=8F=90=E5=89=8D=E5=88=B0=20checkout=20=E4=B9=8B=E5=89=8D,=20?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E6=97=B6=E7=9C=81=E5=8E=BB=E6=8B=89=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E8=A3=85=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 49 ++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 27cd6532e..329853807 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -37,15 +37,43 @@ jobs: comment_recovery: ${{ steps.analyze.outputs.comment_recovery }} comment_bot: ${{ steps.analyze.outputs.comment_bot }} steps: + - name: 预检查是否需要分析(仅 issue_comment 事件) + id: pre-check + if: github.event_name == 'issue_comment' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + state=$(gh issue view "$ISSUE_NUMBER" --json state -q .state) + labels=$(gh issue view "$ISSUE_NUMBER" --json labels -q '.labels[].name') + needs="false" + [ "$state" = "CLOSED" ] && needs="true" + for label in $labels; do + case "$label" in + "缺失快照(missing-snapshot)"|"需补充链接(need-supplement-link)"|"链接无法访问(inaccessible-link)") + needs="true" ;; + esac + done + echo "needs_analysis=$needs" >> "$GITHUB_OUTPUT" + - name: 检出代码仓库 + if: >- + github.event_name != 'issue_comment' || + steps.pre-check.outputs.needs_analysis != 'false' uses: actions/checkout@v7 - name: 配置 Python 运行环境 + if: >- + github.event_name != 'issue_comment' || + steps.pre-check.outputs.needs_analysis != 'false' uses: actions/setup-python@v6 with: python-version: '3.12' - name: 创建工作流所需标签 + if: >- + github.event_name != 'issue_comment' || + steps.pre-check.outputs.needs_analysis != 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -60,7 +88,26 @@ jobs: ISSUE_COMMENT_BODY: ${{ github.event.comment.body || '' }} ISSUE_USER: ${{ github.event.issue.user.login }} ISSUE_ACTION: ${{ github.event_name == 'issue_comment' && 'comment' || github.event.action }} - run: python3 scripts/python/check_issue.py + SKIP_ANALYSIS: ${{ github.event_name == 'issue_comment' && steps.pre-check.outputs.needs_analysis == 'false' }} + run: | + if [ "$SKIP_ANALYSIS" = "true" ]; then + cat >> "$GITHUB_OUTPUT" < Date: Fri, 10 Jul 2026 01:28:05 +0800 Subject: [PATCH 25/90] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20snapshot=5Fp?= =?UTF-8?q?arser.py=20=E5=BF=AB=E7=85=A7=E8=A7=A3=E6=9E=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/snapshot_parser.py | 216 ++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 scripts/python/snapshot_parser.py diff --git a/scripts/python/snapshot_parser.py b/scripts/python/snapshot_parser.py new file mode 100644 index 000000000..3d8cc73e5 --- /dev/null +++ b/scripts/python/snapshot_parser.py @@ -0,0 +1,216 @@ +""" +快照解析模块 + +负责下载 zip 压缩包、提取 snapshot.json、解析为结构化数据。 +本模块只负责数据解析,不负责评论格式化(由 formatter.py 处理)。 + +解析策略: +- 下载 zip 到内存,不解压到磁盘 +- 从 zip 中查找 snapshot.json(兼容不同目录层级) +- 兼容精简模式(顶层字段)和完整模式(appInfo/gkdAppInfo 对象) +- 缺失字段使用合理默认值 +""" + +import io +import json +import zipfile +from dataclasses import dataclass + +import urllib.request +import urllib.error + + +# ── 数据结构 ── + + +@dataclass +class SnapshotInfo: + """快照解析后的结构化信息""" + + # 应用信息 + app_name: str + app_id: str + app_version_name: str + app_version_code: str + + # 界面信息 + activity_id: str + snapshot_id: str + + # 屏幕信息 + screen_width: int + screen_height: int + is_landscape: bool + + # GKD 信息 + gkd_version_name: str + gkd_version_code: str + gkd_user_id: str + + # 设备信息 + device_code: str + device_model: str + device_manufacturer: str + device_brand: str + device_sdk: int + device_release: str + + # 节点统计 + total_nodes: int + visible_nodes: int + clickable_nodes: int + max_depth: int + id_qf_count: int + text_qf_count: int + + # 链接 + original_url: str + converted_url: str + + +# ── 下载与解析 ── + + +def download_and_parse(url: str, converted_url: str = "", timeout: int = 30) -> SnapshotInfo | None: + """ + 下载 zip 并解析快照信息。 + + 参数: + - url:zip 文件的下载地址 + - converted_url:转换后的 GKD 代理链接(用于 Bot 评论展示) + - timeout:下载超时时间(秒) + + 返回 SnapshotInfo,下载或解析失败时返回 None。 + """ + zip_data = _download_zip(url, timeout) + if not zip_data: + return None + + snapshot_json = _extract_snapshot_json(zip_data) + if not snapshot_json: + return None + + return _parse_snapshot(snapshot_json, url, converted_url) + + +# ── 内部函数 ── + + +def _download_zip(url: str, timeout: int) -> bytes | None: + """ + 下载 zip 文件到内存。 + + 返回 zip 的字节数据,失败时返回 None。 + """ + try: + req = urllib.request.Request(url, method="GET") + req.add_header("User-Agent", "GKD-Issue-Checker/1.0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + except Exception: + return None + + +def _extract_snapshot_json(zip_data: bytes) -> dict | None: + """ + 从 zip 字节数据中提取 snapshot.json 的内容。 + + 查找 zip 内所有 .json 文件,优先选择名为 snapshot.json 的。 + 兼容不同目录层级(根目录或子目录)。 + """ + try: + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + # 优先查找 snapshot.json + for name in zf.namelist(): + if name.endswith("snapshot.json"): + with zf.open(name) as f: + return json.loads(f.read().decode("utf-8")) + + # 回退:查找任意 .json 文件 + for name in zf.namelist(): + if name.endswith(".json"): + with zf.open(name) as f: + return json.loads(f.read().decode("utf-8")) + except Exception: + pass + + return None + + +def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> SnapshotInfo: + """ + 将 snapshot.json 解析为 SnapshotInfo。 + + 兼容精简模式(顶层 appName 等字段)和完整模式(appInfo 对象)。 + 缺失字段使用合理默认值。 + """ + # 应用信息:优先完整模式 appInfo,回退精简模式顶层字段 + app_info = data.get("appInfo", {}) or {} + app_name = app_info.get("name") or data.get("appName", "") + app_version_name = str(app_info.get("versionName") or data.get("appVersionName", "")) + app_version_code = str(app_info.get("versionCode") or data.get("appVersionCode", "")) + + # GKD 信息:优先 gkdAppInfo,回退顶层字段 + gkd_info = data.get("gkdAppInfo", {}) or {} + gkd_version_name = str(gkd_info.get("versionName") or data.get("gkdVersionName", "")) + gkd_version_code = str(gkd_info.get("versionCode") or data.get("gkdVersionCode", "")) + gkd_user_id = str(gkd_info.get("userId", "")) + + # 设备信息 + device = data.get("device", {}) or {} + + # 节点统计 + nodes = data.get("nodes", []) or [] + total_nodes = len(nodes) + visible_nodes = 0 + clickable_nodes = 0 + max_depth = 0 + id_qf_count = 0 + text_qf_count = 0 + + for node in nodes: + attr = node.get("attr", {}) or {} + + if attr.get("visibleToUser", False): + visible_nodes += 1 + if attr.get("clickable", False): + clickable_nodes += 1 + + depth = attr.get("depth", 0) + if depth > max_depth: + max_depth = depth + + # idQf / textQf 缺失视为 null,仅 true 时计数 + if node.get("idQf") is True: + id_qf_count += 1 + if node.get("textQf") is True: + text_qf_count += 1 + + return SnapshotInfo( + app_name=app_name, + app_id=data.get("appId", ""), + app_version_name=app_version_name, + app_version_code=app_version_code, + activity_id=data.get("activityId", ""), + snapshot_id=str(data.get("id", "")), + screen_width=data.get("screenWidth", 0), + screen_height=data.get("screenHeight", 0), + is_landscape=data.get("isLandscape", False), + gkd_version_name=gkd_version_name, + gkd_version_code=gkd_version_code, + gkd_user_id=gkd_user_id, + device_code=device.get("device", ""), + device_model=device.get("model", ""), + device_manufacturer=device.get("manufacturer", ""), + device_brand=device.get("brand", ""), + device_sdk=device.get("sdkInt", 0), + device_release=device.get("release", ""), + total_nodes=total_nodes, + visible_nodes=visible_nodes, + clickable_nodes=clickable_nodes, + max_depth=max_depth, + id_qf_count=id_qf_count, + text_qf_count=text_qf_count, + original_url=original_url, + converted_url=converted_url, + ) \ No newline at end of file From 4fe52819af5cdc17a21bd02eb3e03f36913478e6 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 10 Jul 2026 01:29:07 +0800 Subject: [PATCH 26/90] =?UTF-8?q?feat:=20formatter.py=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=20Bot=20=E8=AF=84=E8=AE=BA,=20=E5=9F=BA=E4=BA=8E=20SnapshotInf?= =?UTF-8?q?o=20=E5=88=86=E7=BB=84=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/formatter.py | 288 +++++++++++++++++++++++++++++------- 1 file changed, 235 insertions(+), 53 deletions(-) diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index af31dc459..3a4abf1a0 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -4,13 +4,13 @@ 负责生成所有 Bot 评论的 Markdown 内容,包括: - 各类警告评论(缺失快照 / 不可访问快照 / 链接无法访问 / 不确定) - 编辑/评论恢复评论 -- 快照转换 Bot 评论(按 App > Activity 分组) +- 快照转换 Bot 评论(基于 SnapshotInfo 按 App > Activity 分组) 每类评论使用独立的 HTML 标记,供 YAML 工作流中 find-comment 按场景查找。 本模块只负责内容生成,不负责评论发布(由 YAML 工作流完成)。 """ -from converter import ConvertedLink +from snapshot_parser import SnapshotInfo # ── 警告评论生成 ── @@ -70,74 +70,256 @@ def build_recovery_comment(user: str) -> str: # ── Bot 转换评论生成 ── -def build_bot_comment(converted: list[ConvertedLink]) -> str: +def build_bot_comment(snapshots: list[SnapshotInfo], gkd_links: list[tuple[str, str]]) -> str: """ 生成快照转换 Bot 评论内容。 - 格式: - ### AppName - #### ActivityName - [timestamp](转换后URL) 或 [display_text](转换后URL) - - 不匹配文件名模式的附件不分组,逐条列出。 + 主区域:App 标题 + Activity 行(快查/深度/可点击/节点数)+ 链接 + 折叠区:App 详细信息表 + 设备信息表 - 底部
折叠区包含所有原始附件 URL(快速复制)。 + 参数: + - snapshots:解析成功的 SnapshotInfo 列表(按 Activity 去重后) + - gkd_links:无法解析的 GKD 链接列表 [(display_text, converted_url), ...] """ - if not converted: + if not snapshots and not gkd_links: return "" - grouped: dict[str, dict[str, list[ConvertedLink]]] = {} - ungrouped: list[ConvertedLink] = [] + lines: list[str] = [] + + # 按 appId 分组 + app_groups = _group_by_app(snapshots) + + # 主区域:按 App 输出 + for app_key, app_snapshots in app_groups.items(): + _render_app_section(lines, app_key, app_snapshots) + + # GKD 链接(无法下载解析的) + if gkd_links: + lines.append("**GKD 链接**") + link_parts = [f"[{dt}]({url})" for dt, url in gkd_links] + lines.append(" · ".join(link_parts)) + lines.append("") + + # 折叠区:详细信息 + detail_lines = _render_detail_section(snapshots) + if detail_lines: + lines.append("
") + lines.append("详细信息") + lines.append("") + lines.extend(detail_lines) + lines.append("
") + + return "\n".join(lines) + + +# ── 分组与去重 ── + + +def _group_by_app(snapshots: list[SnapshotInfo]) -> dict[str, list[SnapshotInfo]]: + """ + 按 appId 分组,同 appId 下按 activityId 分组。 + + 同 activityId 只保留第一个(代表快照),其余只记录链接。 + 返回有序字典:key = "appName `appId` versionName" + """ + from collections import OrderedDict - for item in converted: - if item.app_name and item.activity_name: - grouped.setdefault(item.app_name, {}).setdefault( - item.activity_name, [] - ).append(item) + groups: dict[str, list[SnapshotInfo]] = OrderedDict() + seen_activities: dict[str, list[SnapshotInfo]] = {} + + for snap in snapshots: + app_key = f"{snap.app_name} `{snap.app_id}` {snap.app_version_name}" + groups.setdefault(app_key, []) + + act_key = f"{snap.app_id}|{snap.activity_id}" + if act_key not in seen_activities: + seen_activities[act_key] = [snap] + groups[app_key].append(snap) else: - ungrouped.append(item) + seen_activities[act_key].append(snap) + + return groups + + +def _get_activity_links(snapshots: list[SnapshotInfo], activity_id: str) -> list[tuple[str, str]]: + """ + 获取同一 Activity 下所有快照的链接列表。 + + 返回 [(snapshot_id, converted_url), ...] + """ + links = [] + for snap in snapshots: + if snap.activity_id == activity_id: + display = snap.snapshot_id or snap.original_url.split("/")[-1] + url = snap.converted_url or snap.original_url + links.append((display, url)) + return links + + +# ── 主区域渲染 ── + + +def _render_app_section(lines: list[str], app_key: str, snapshots: list[SnapshotInfo]): + """ + 渲染单个 App 的主区域内容。 + + 格式: + ## AppName `appId` versionName + device_model · Android release · GKD version + + **Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 + [id1](url) · [id2](url) + """ + # App 标题 + lines.append(f"## {app_key}") + + # App 副标题:设备 + Android + GKD(取第一个快照的设备信息) + first = snapshots[0] + subtitle_parts = [] + if first.device_model: + subtitle_parts.append(first.device_model) + if first.device_release: + subtitle_parts.append(f"Android {first.device_release}") + if first.gkd_version_name: + subtitle_parts.append(f"GKD {first.gkd_version_name}") + if subtitle_parts: + lines.append(" · ".join(subtitle_parts)) + lines.append("") + + # 按 Activity 渲染 + seen_activities: set[str] = set() + for snap in snapshots: + if snap.activity_id in seen_activities: + continue + seen_activities.add(snap.activity_id) + + _render_activity_line(lines, snap) + + +def _render_activity_line(lines: list[str], snap: SnapshotInfo): + """ + 渲染单个 Activity 行。 + + 格式:**Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 + """ + # Activity 名称取最后一段(类名简写) + act_display = _short_activity_name(snap.activity_id) + + # 统计信息行 + stats = ( + f"快查 ID:{snap.id_qf_count} Text:{snap.text_qf_count}" + f" · 深度{snap.max_depth}" + f" · 可点击{snap.clickable_nodes}" + f" · {snap.total_nodes}节点" + ) + lines.append(f"**{act_display}** — {stats}") + + # 链接行 + link_url = snap.converted_url or snap.original_url + link_display = snap.snapshot_id or _extract_filename(snap.original_url) + lines.append(f"[{link_display}]({link_url})") + lines.append("") + + +# ── 折叠区渲染 ── + + +def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: + """ + 渲染折叠区内容。 + + 包含: + - 按 App 分组的详细信息表(可见/分辨率/方向/appVersionCode/GKD/userId) + - 设备信息表(去重) + """ + if not snapshots: + return [] lines: list[str] = [] - for app_name in grouped: - activities = grouped[app_name] - lines.append(f"### {app_name}") - for activity_name in activities: - items = activities[activity_name] - lines.append(f"#### {activity_name}") - for item in items: - lines.append(_format_link_line(item)) - lines.append("") - - if ungrouped: - for item in ungrouped: - lines.append(_format_link_line(item)) + # 按 App 分组渲染详细信息表 + app_groups: dict[str, list[SnapshotInfo]] = {} + for snap in snapshots: + app_key = f"{snap.app_name} `{snap.app_id}`" + app_groups.setdefault(app_key, []).append(snap) + + for app_key, app_snaps in app_groups.items(): + lines.append(f"**{app_key}**") + lines.append("") + lines.append( + "| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |" + ) + lines.append( + "|----------|------|--------|------|----------------|-----|--------|" + ) + for snap in app_snaps: + orientation = "横屏" if snap.is_landscape else "竖屏" + resolution = f"{snap.screen_width}×{snap.screen_height}" + gkd_info = f"{snap.gkd_version_name} ({snap.gkd_version_code})" if snap.gkd_version_name else "" + act_display = _short_activity_name(snap.activity_id) + lines.append( + f"| {act_display} | {snap.visible_nodes} | {resolution} | {orientation} " + f"| {snap.app_version_code} | {gkd_info} | {snap.gkd_user_id} |" + ) lines.append("") - lines.append("
") - lines.append("快速复制") - lines.append("") - lines.append("## 快速复制") - lines.append("```") - for item in converted: - lines.append(item.original_url) - lines.append("```") - lines.append("
") + # 设备信息表(去重) + devices = _deduplicate_devices(snapshots) + if len(devices) >= 1: + lines.append("**设备信息**") + lines.append("") + lines.append("| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |") + lines.append("|------|------|--------|------|-----|---------|") + for dev in devices: + lines.append( + f"| {dev['code']} | {dev['model']} | {dev['manufacturer']} " + f"| {dev['brand']} | {dev['sdk']} | {dev['release']} |" + ) + lines.append("") - return "\n".join(lines) + return lines -def _format_link_line(item: ConvertedLink) -> str: +def _deduplicate_devices(snapshots: list[SnapshotInfo]) -> list[dict]: """ - 格式化单条链接行。 + 设备信息去重。 - 优先级: - 1. 有 display_text 时:[display_text](converted_url) - 2. 有 timestamp 时:[timestamp](converted_url) - 3. 否则:直接输出 converted_url + 按 (device_code, device_model) 组合去重。 """ - if item.display_text: - return f"[{item.display_text}]({item.converted_url})" - if item.timestamp: - return f"[{item.timestamp}]({item.converted_url})" - return item.converted_url \ No newline at end of file + seen: set[tuple[str, str]] = set() + result: list[dict] = [] + + for snap in snapshots: + key = (snap.device_code, snap.device_model) + if key in seen: + continue + seen.add(key) + result.append({ + "code": snap.device_code, + "model": snap.device_model, + "manufacturer": snap.device_manufacturer, + "brand": snap.device_brand, + "sdk": str(snap.device_sdk), + "release": snap.device_release, + }) + + return result + + +# ── 工具函数 ── + + +def _short_activity_name(activity_id: str) -> str: + """ + Activity 类名取最后一段。 + + 例如:com.mihoyo.cloudgame.main.MiHoYoCloudMainActivity → MiHoYoCloudMainActivity + """ + if "." in activity_id: + return activity_id.rsplit(".", 1)[-1] + return activity_id + + +def _extract_filename(url: str) -> str: + """从 URL 中提取文件名""" + return url.rsplit("/", 1)[-1] if "/" in url else url \ No newline at end of file From f7006cc57da5945bb2a9e7715326a7c6c335c5c8 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 10 Jul 2026 01:30:03 +0800 Subject: [PATCH 27/90] =?UTF-8?q?feat:=20check=5Fissue.py=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E5=BF=AB=E7=85=A7=E8=A7=A3=E6=9E=90,=20=E5=90=8CActiv?= =?UTF-8?q?ity=E5=8F=AA=E4=B8=8B=E8=BD=BD=E4=B8=80=E4=B8=AA=E4=BB=A3?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/check_issue.py | 84 +++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 8 deletions(-) diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py index 73958060c..a19a5ec4e 100644 --- a/scripts/python/check_issue.py +++ b/scripts/python/check_issue.py @@ -38,6 +38,7 @@ gkd_to_gh_attachment_url, ) from converter import convert_github_attachments +from snapshot_parser import download_and_parse, SnapshotInfo from formatter import ( build_warning_missing, build_warning_unreachable, @@ -141,14 +142,13 @@ def main(): net_result.uncertain_detail, ) - # ── 第五步:链接转换 + Bot 评论生成(仅当含有 GitHub 附件链接时) ── - attachment_links = [lnk for lnk in links if lnk.kind == "github_attachment"] - if attachment_links: - converted = convert_github_attachments(attachment_links) - has_convertible = "true" if converted else "false" - if converted: - comment_body = build_bot_comment(converted) - comment_bot = "\n" + comment_body + # ── 第五步:链接转换 + 快照解析 + Bot 评论生成 ── + # 仅当含有可下载的快照链接(GH 附件或 GKD 分享链接)时执行 + snapshots, gkd_links = _parse_all_snapshots(links) + if snapshots or gkd_links: + has_convertible = "true" + comment_body = build_bot_comment(snapshots, gkd_links) + comment_bot = "\n" + comment_body # ── 第六步:编辑/评论恢复判断 ── # 当 edited 或 issue_comment 触发且所有检查均通过时,触发恢复流程 @@ -218,6 +218,74 @@ def _check_all_links(links: list) -> _NetworkCheckResult: return result +def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: + """ + 下载并解析所有快照链接,同 Activity 只下载一个代表。 + + 返回: + - snapshots:解析成功的 SnapshotInfo 列表 + - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] + """ + from converter import GKD_PROXY_TEMPLATE + + snapshots: list[SnapshotInfo] = [] + gkd_links: list[tuple[str, str]] = [] + + # 已下载的 Activity 集合,用于去重 + seen_activities: set[str] = set() + + # 先处理 GitHub 附件链接 + for lnk in links: + if lnk.kind != "github_attachment": + continue + + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + snap = download_and_parse(lnk.url, converted_url) + + if snap is None: + # 下载失败,仍作为可转换链接保留 + gkd_links.append((lnk.display_text or _extract_filename(lnk.url), converted_url)) + continue + + act_key = f"{snap.app_id}|{snap.activity_id}" + if act_key in seen_activities: + # 同 Activity 已有代表,只记录链接 + gkd_links.append((snap.snapshot_id or _extract_filename(lnk.url), converted_url)) + else: + seen_activities.add(act_key) + snapshots.append(snap) + + # 再处理 GKD 分享链接 + for lnk in links: + if lnk.kind != "gkd": + continue + + gh_url = gkd_to_gh_attachment_url(lnk.url) + if not gh_url: + continue + + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + snap = download_and_parse(gh_url, converted_url) + + if snap is None: + gkd_links.append((lnk.display_text or lnk.url, converted_url)) + continue + + act_key = f"{snap.app_id}|{snap.activity_id}" + if act_key in seen_activities: + gkd_links.append((snap.snapshot_id or lnk.url, converted_url)) + else: + seen_activities.add(act_key) + snapshots.append(snap) + + return snapshots, gkd_links + + +def _extract_filename(url: str) -> str: + """从 URL 中提取文件名""" + return url.rsplit("/", 1)[-1] if "/" in url else url + + def _output(**kwargs): """将所有分析结果写入 GITHUB_OUTPUT。""" for key, value in kwargs.items(): From 152343ce3e45df3bc3d72204ad75624a9144f4bc Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 10 Jul 2026 01:30:43 +0800 Subject: [PATCH 28/90] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E8=A7=84?= =?UTF-8?q?=E5=88=99,=20=E6=96=B0=E5=A2=9E=20snapshot=5Fparser=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=92=8C=20Bot=20=E8=AF=84=E8=AE=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/project_rules.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 3f38aa6b5..60301a408 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -240,20 +240,36 @@ GKD 分享链接(`https://i.gkd.li/i/{id}`)指向审查工具 URL,无法 ## Bot 评论格式 -### 分组规则 +### 快照解析策略 -从附件文件名中提取 `{App}_{Activity}-{timestamp}.zip` 模式: -- `### AppName`(一级标题) -- `#### ActivityName`(二级标题) -- `[timestamp](转换后URL)` 或 `[display_text](转换后URL)` +- 下载 zip 到内存,解压读取 snapshot.json +- 同 Activity 只下载一个代表快照,其余只记录链接 +- GKD 分享链接先转 GH 附件 URL 再下载解析 +- 下载失败时仍作为可转换链接保留 -### 不匹配文件名模式 +### 主区域(直接可见) -文件名不符合 `{App}_{Activity}-{timestamp}.zip` 的附件,不分组,逐条列出。 +``` +## AppName `appId` versionName +device_model · Android release · GKD version + +**Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 +[snapshot_id](converted_url) + +**GKD 链接** +[display](url) · [display](url) +``` + +信息分层: +- App 标题:appName + appId + appVersionName +- App 副标题:device_model + Android版本 + GKD版本(同App只显示一次) +- Activity 行:activityId(取最后一段) + 快查ID/Text数 + 最大深度 + 可点击数 + 总节点数 +- 链接行:snapshot_id 链接到 GKD 代理 URL -### 快速复制折叠区 +### 折叠区(详细信息) -评论底部包含 `
` 折叠区,列出所有原始附件 URL。 +- 按 App 分组的详细信息表:可见节点 / 分辨率 / 方向 / appVersionCode / GKD版本号+构建号 / userId +- 设备信息表(去重):代号 / 型号 / 制造商 / 品牌 / SDK / Android --- @@ -265,6 +281,7 @@ scripts/python/ ├── extractor.py # 链接提取与分类 ├── checker.py # 两类检查(不可访问快照/网络)+ GKD→GH 转换 ├── converter.py # GitHub 附件 → GKD 代理链接转换 + ├── snapshot_parser.py # 快照 zip 下载+解析,输出 SnapshotInfo ├── formatter.py # Bot 评论 Markdown 格式化生成 └── utils.py # 公共工具函数(GITHUB_OUTPUT 写入等) ``` From c3560da6b2b6706a8fd549894d162314871c8a61 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 10 Jul 2026 06:49:59 +0800 Subject: [PATCH 29/90] docs: snapshot api --- docs/api/snapshot.md | 251 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/api/snapshot.md diff --git a/docs/api/snapshot.md b/docs/api/snapshot.md new file mode 100644 index 000000000..93c32f94b --- /dev/null +++ b/docs/api/snapshot.md @@ -0,0 +1,251 @@ +# GKD 快照数据 API 文档 + +## 1. 概述 + +GKD(自动跳过广告工具)在运行过程中会通过无障碍服务捕获当前界面的视图树快照(Snapshot),并以 JSON 格式存储。该快照包含了应用信息、设备信息、GKD 自身状态以及完整的视图节点树,可用于离线分析、规则编写与调试。 + +本文档定义了 GKD 快照的数据结构、字段含义以及基于快照进行节点匹配时需遵循的规则,特别是针对 **快速查询(fastQuery)** 优化的支持。 + +--- + +## 2. 快照根对象 + +快照 JSON 的顶层是一个对象,包含以下字段: + +| 字段 | 类型 | 必选 | 说明 | +| ---------------- | ---------- | ---- | ------------------------------------------------------------ | +| `id` | number | 是 | 快照的唯一标识(通常为时间戳) | +| `appId` | string | 是 | 目标应用的包名 | +| `activityId` | string | 是 | 当前界面的 Activity 完整类名 | +| `screenHeight` | int | 是 | 屏幕高度(像素) | +| `screenWidth` | int | 是 | 屏幕宽度(像素) | +| `isLandscape` | boolean | 是 | 是否为横屏 | +| `appInfo` | object | 否 | 目标应用的详细信息(见 2.1),与顶层 `appName` 等互斥 | +| `appName` | string | 否 | 目标应用的显示名称(精简模式) | +| `appVersionCode` | int/string | 否 | 目标应用的版本号(精简模式) | +| `appVersionName` | string | 否 | 目标应用的版本名称(精简模式) | +| `gkdAppInfo` | object | 否 | GKD 自身的详细信息(见 2.2),与顶层 `gkdVersionCode` 等互斥 | +| `gkdVersionCode` | int | 否 | GKD 的版本号(精简模式) | +| `gkdVersionName` | string | 否 | GKD 的版本名称(精简模式) | +| `device` | object | 是 | 设备信息(见 2.3) | +| `nodes` | array | 是 | 视图节点数组,每个元素为一个节点对象(见第 3 节) | + +### 2.1 `appInfo` 对象(完整模式) + +| 字段 | 类型 | 说明 | +| ------------- | ------- | ----------------------- | +| `id` | string | 包名(同 `appId`) | +| `name` | string | 应用名称 | +| `versionCode` | int | 版本号 | +| `versionName` | string | 版本名称 | +| `isSystem` | boolean | 是否为系统应用 | +| `mtime` | number | 最后修改时间戳(毫秒) | +| `hidden` | boolean | 是否隐藏 | +| `enabled` | boolean | 是否启用 | +| `userId` | int | 用户 ID(0 表示主用户) | + +### 2.2 `gkdAppInfo` 对象(完整模式) + +| 字段 | 类型 | 说明 | +| ------------- | ------- | -------------------------- | +| `id` | string | GKD 包名(`li.songe.gkd`) | +| `name` | string | GKD 名称 | +| `versionCode` | int | GKD 版本号 | +| `versionName` | string | GKD 版本名称 | +| `isSystem` | boolean | 是否系统应用 | +| `mtime` | number | 最后修改时间戳 | +| `hidden` | boolean | 是否隐藏 | +| `enabled` | boolean | 是否启用 | +| `userId` | int | 用户 ID | + +### 2.3 `device` 对象 + +| 字段 | 类型 | 说明 | +| -------------- | ------ | ----------------------- | +| `device` | string | 设备代号(如 `PD2445`) | +| `model` | string | 用户可见的设备型号 | +| `manufacturer` | string | 制造商 | +| `brand` | string | 品牌 | +| `sdkInt` | int | Android SDK 版本号 | +| `release` | string | Android 系统版本字符串 | + +--- + +## 3. 节点对象(`nodes` 数组元素) + +每个节点代表视图树中的一个视图(View)或视图组(ViewGroup)。 + +| 字段 | 类型 | 必选 | 说明 | +| -------- | ------------ | ---- | ------------------------------------------------------------------- | +| `id` | int | 是 | 节点在当前数组中的唯一自增标识,从 0 开始 | +| `pid` | int | 是 | 父节点的 `id`,根节点为 `-1` | +| `idQf` | boolean/null | 是 | **合格标志**:`attr.id` 是否稳定且可用于快速查询(见第 5 节) | +| `textQf` | boolean/null | 是 | **合格标志**:`attr.text` 是否静态稳定且可用于快速查询(见第 5 节) | +| `attr` | object | 是 | 节点的详细属性(见第 4 节) | + +> **注意**:历史快照中可能缺少 `idQf` 或 `textQf` 字段(即 `undefined`),解析时应视为 `null`。 + +--- + +## 4. 属性对象(`attr`) + +描述视图的具体布局、内容及交互属性。 + +| 字段 | 类型 | 说明 | +| --------------- | ----------- | ------------------------------------------ | +| `id` | string/null | 视图的资源 ID(如 `android:id/content`) | +| `vid` | string/null | 视图的资源名称(ID 的简写) | +| `name` | string | 视图的类名(如 `android.widget.TextView`) | +| `text` | string/null | 视图显示的文本内容 | +| `desc` | string/null | 内容描述(无障碍描述) | +| `clickable` | boolean | 是否可点击 | +| `focusable` | boolean | 是否可获得焦点 | +| `checkable` | boolean | 是否可勾选 | +| `checked` | boolean | 是否已勾选 | +| `editable` | boolean | 是否可编辑 | +| `longClickable` | boolean | 是否可长按 | +| `visibleToUser` | boolean | 是否对用户可见 | +| `left` | int | 视图左边缘坐标(像素) | +| `top` | int | 视图上边缘坐标 | +| `right` | int | 视图右边缘坐标 | +| `bottom` | int | 视图下边缘坐标 | +| `width` | int | 视图宽度(`right - left`) | +| `height` | int | 视图高度(`bottom - top`) | +| `childCount` | int | 子节点数量(仅视图组有效) | +| `index` | int | 在父节点中的位置索引 | +| `depth` | int | 在视图树中的深度(根节点为 0) | + +--- + +## 5. 合格标志(`idQf` / `textQf`)与快速查询 + +### 5.1 定义 + +- **`idQf`**(ID Qualified):若为 `true`,表示 `attr.id` 是稳定的、来自 Android 资源的视图 ID,可通过 `findAccessibilityNodeInfosByViewId` 快速定位。 +- **`textQf`**(Text Qualified):若为 `true`,表示 `attr.text` 是静态固定文本(不是时间、计数器等动态内容),可通过 `findAccessibilityNodeInfosByText` 快速定位。 + +### 5.2 快速查询优化 + +GKD 在匹配规则时,如果选择器使用了 `[vid="..."]` 或 `[text="..."]`,且对应节点的 `idQf` 或 `textQf` 为 `true`,则会调用 Android 系统的快速查找 API,**避免手动遍历整个视图树**,极大提升匹配效率。 + +- **适用条件**:节点必须在快照面板中被标记为“可快速查找”(即 `idQf === true` 或 `textQf === true`),否则快速查询 API 可能返回空或错误结果。 +- **选择器示例**: + - `[vid="com.example:id/confirm_button"]` → 依赖 `idQf` + - `[text="确定"]` → 依赖 `textQf` + +### 5.3 匹配规则 + +在编写或解析规则时,**必须遵守以下约束**: + +| 条件 | 行为 | +| ---------------------------- | ----------------------------------------------------------------------- | +| `idQf === true` | 可以安全地使用 `attr.id` 进行精确匹配,并可启用快速查询 | +| `idQf === false` 或 `null` | **不应**使用 `attr.id` 作为匹配条件(ID 可能动态变化或不可靠) | +| `textQf === true` | 可以安全地使用 `attr.text` 进行完全匹配,并可启用快速查询 | +| `textQf === false` 或 `null` | **不应**使用 `attr.text` 作为固定文本匹配(例如倒计时“03:59:45”应忽略) | + +> **解析器实现要求**:在匹配节点前,必须检查对应的 QF 标志。仅当标志为 `true` 时,才将该属性纳入匹配条件。 + +--- + +## 6. 示例 + +### 6.1 快照根对象(精简模式) + +```json +{ + "id": 1711547793221, + "appId": "com.miHoYo.cloudgames.ys", + "activityId": "com.mihoyo.cloudgame.main.MiHoYoCloudMainActivity", + "appName": "云·原神", + "appVersionCode": 400000014, + "appVersionName": "4.5.0", + "screenHeight": 1080, + "screenWidth": 2400, + "isLandscape": true, + "gkdVersionCode": 27, + "gkdVersionName": "1.7.2", + "device": { ... }, + "nodes": [ ... ] +} +``` + +### 6.2 节点对象(含合格标志) + +```json +{ + "id": 5, + "pid": 3, + "idQf": true, + "textQf": true, + "attr": { + "id": "com.ss.android.article.lite:id/dcw", + "vid": "dcw", + "name": "android.widget.TextView", + "text": "领取成功!继续观看视频领取更多时长", + "clickable": false, + "visibleToUser": true, + "left": 107, + "top": 1470, + "right": 974, + "bottom": 1538, + "width": 867, + "height": 68, + "childCount": 0, + "index": 1, + "depth": 4 + } +} +``` + +### 6.3 节点对象(动态文本,不可用于匹配) + +```json +{ + "id": 9, + "pid": 7, + "idQf": true, + "textQf": false, + "attr": { + "id": "com.ss.android.article.lite:id/dcs", + "text": "03:59:45", + ... + } +} +``` + +> 该节点的 `textQf` 为 `false`,表示文本是动态倒计时,不应作为固定文本匹配。 + +--- + +## 7. 错误处理与兼容性 + +### 7.1 缺失字段 +- 若 `idQf` 或 `textQf` 缺失(`undefined`),解析时应视为 `null`,按“不合格”处理。 +- 若 `appInfo` 缺失,应回退读取 `appName`、`appVersionCode` 等顶层字段。 +- 若 `gkdAppInfo` 缺失,应回退读取 `gkdVersionCode`、`gkdVersionName`。 + +### 7.2 树结构异常 +- **孤儿节点**:`pid` 指向不存在的 `id` → 将该节点视为根节点。 +- **循环引用**:检测到父子循环 → 终止遍历,记录错误日志。 +- **重复 `id`**:`nodes` 数组中 `id` 必须唯一,若重复则后者覆盖前者(或报错)。 + +### 7.3 快速查询失败回退 +- 即使 `idQf === true`,系统 API 也可能因节点未附加到窗口而返回空。解析器应实现回退策略:快速查询失败后,自动切换为手动遍历。 + +### 7.4 版本兼容 +- 旧版 GKD 生成的快照可能不含 `idQf` / `textQf`,此时默认所有节点的这两个标志均为 `null`,即**无法使用快速查询**,需完全遍历。 + +--- + +## 8. 相关资源 + +- GKD 官方文档:[快速查询](https://gkd.li/guide/optimize#fast-query) +- 无障碍服务 API:[AccessibilityNodeInfo](https://developer.android.google.cn/reference/android/view/accessibility/AccessibilityNodeInfo) + +--- + +*文档版本:1.0* +*最后更新:2026-04-06* +*author:DeepSeek* +'内容由AI生成,请仔细甄别' \ No newline at end of file From 09a8a235cc2a0932aa355da3e848b418cee2c5a8 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 10 Jul 2026 07:19:32 +0800 Subject: [PATCH 30/90] =?UTF-8?q?fix:=20gkd=E9=93=BE=E6=8E=A5=E8=AF=AF?= =?UTF-8?q?=E5=A5=97=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/check_issue.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py index a19a5ec4e..1db314715 100644 --- a/scripts/python/check_issue.py +++ b/scripts/python/check_issue.py @@ -255,7 +255,7 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st seen_activities.add(act_key) snapshots.append(snap) - # 再处理 GKD 分享链接 + # 再处理 GKD 分享链接(GKD 链接原样保留,不套代理模板) for lnk in links: if lnk.kind != "gkd": continue @@ -264,16 +264,15 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st if not gh_url: continue - converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - snap = download_and_parse(gh_url, converted_url) + snap = download_and_parse(gh_url, lnk.url) if snap is None: - gkd_links.append((lnk.display_text or lnk.url, converted_url)) + gkd_links.append((lnk.display_text or lnk.url, lnk.url)) continue act_key = f"{snap.app_id}|{snap.activity_id}" if act_key in seen_activities: - gkd_links.append((snap.snapshot_id or lnk.url, converted_url)) + gkd_links.append((snap.snapshot_id or lnk.url, lnk.url)) else: seen_activities.add(act_key) snapshots.append(snap) From bc764489b26d2a60e39acd68e9b4b83e37a7ef52 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 10 Jul 2026 10:04:32 +0800 Subject: [PATCH 31/90] ci: job name --- .github/workflows/issue_content_check.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 329853807..2cebc2304 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -145,10 +145,10 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue close "$ISSUE_NUMBER" --reason "not planned" - # ── 线性步骤二:不可访问快照 → 打标签 + 评论(非致命,不阻断后续) ── + # ── 线性步骤二:本地快照链接 → 打标签 + 评论(非致命,不阻断后续) ── handle-unreachable-snapshot: - name: 处理不可访问快照 + name: 处理本地快照链接 needs: [analyze, handle-missing-snapshot] if: >- always() && @@ -171,7 +171,7 @@ jobs: comment-author: 'github-actions[bot]' body-includes: '' - - name: 发布不可访问快照提醒评论 + - name: 发布本地快照提醒评论 uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-warning.outputs.comment-id }} From c6e41596285707a49b099f985d735b52d35ae6e1 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 04:11:41 +0800 Subject: [PATCH 32/90] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20CLAUDE.md=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=8A=A8=E6=8F=90=E4=BA=A4=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=92=8C=20Python=20=E6=A8=A1=E5=9D=97=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c3a3a677b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,202 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 提供代码协作指导。 + +## 项目概述 + +GKD 订阅规则仓库 — 为 [GKD](https://gkd.li/) 提供第三方订阅规则。GKD 是一款基于 Android 无障碍服务的工具,可自动关闭广告、弹窗和不需要的 UI 元素。规则以 TypeScript 文件编写,定义 UI 节点选择器来匹配 Android 视图层级快照。 + +## 开发命令 + +```bash +pnpm install # 安装依赖 +pnpm run check # TypeScript 类型检查 + 订阅验证(选择器语法、规则结构) +pnpm run build # TypeScript 类型检查 + 构建 dist/gkd.json5 + 更新 dist/README.md 和根目录 README.md +pnpm run lint # ESLint 自动修复(移除未使用的导入、Prettier 格式化) +pnpm run format # Prettier 格式化所有源文件 +``` + +**单文件验证**:没有单文件检查模式。`pnpm run check` 验证整个订阅树。修改规则后请运行此命令。 + +**Git 钩子**(通过 `simple-git-hooks` + `lint-staged`): +- pre-commit:对暂存的 `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs` 文件执行 ESLint + Prettier;对 `.json` 文件执行 Prettier +- commit-msg:commitlint(遵循 conventional commits,详见 `commitlint.config.ts`) +- pre-push:`pnpm run check` + +## 自动提交规范 + +每当你完成一个独立功能的开发,或修复完一个 Bug 并验证通过后,请自动运行 `git commit` 提交代码,并生成一句简洁的中文 commit message。 + +**触发条件:** +- 完成一个独立功能的开发 +- 修复完一个 Bug 并验证通过 +- 重构完成并确认功能正常 + +**Commit message 格式:** +- 使用中文描述 +- 简洁明了,一句话概括改动内容 +- 示例:`修复 Python 脚本编码问题`、`新增链接检查验证工具`、`优化模块依赖结构` + +## 架构设计 + +### 核心流程 + +``` +src/apps/*.ts ──┐ +src/globalGroups.ts ──┤──▶ src/subscription.ts ──▶ scripts/check.ts ──▶ scripts/build.ts ──▶ dist/gkd.json5 +src/categories.ts ──┘ (defineGkdSubscription) (checkSubscription) (updateDist + updateReadMeMd) +``` + +- `src/subscription.ts` — 入口文件。调用 `batchImportApps()` 自动导入 `src/apps/` 下所有 `.ts` 文件,通过 `defineGkdSubscription()` 组装订阅对象。 +- `src/apps/` — 每个 Android 应用一个 `.ts` 文件,以包名命名(如 `com.tencent.mm.ts`)。导出 `defineGkdApp()`,包含 `id`、`name` 和 `groups[]`。 +- `src/categories.ts` — 定义规则分类(开屏广告、青少年模式、更新提示等),包含 `key`、`name` 和默认 `enable` 状态。规则组名称**必须**以分类名称开头(如 `分段广告-xxx`)。 +- `src/globalGroups.ts` — 跨应用的全局规则(跳过开屏广告、更新提示、青少年模式)。使用 `src/globalDefaultApps.ts` 中的黑白名单。 +- `scripts/check.ts` — 通过 `@gkd-kit/tools` 验证订阅和 API 版本。 +- `scripts/build.ts` — 构建 `dist/gkd.json5`、`dist/README.md`,并从 `Template.md` 更新根目录 `README.md`。 + +### 关键依赖 + +- `@gkd-kit/define` — `defineGkdApp`、`defineGkdSubscription`、`defineGkdCategories`、`defineGkdGlobalGroups` +- `@gkd-kit/api` — TypeScript 类型(`RawApp`、`RawAppGroup` 等) +- `@gkd-kit/tools` — `batchImportApps`、`checkSubscription`、`checkApiVersion`、`updateDist` + +### 规则结构 + +每个应用规则文件遵循以下模式: +```ts +import { defineGkdApp } from '@gkd-kit/define'; + +export default defineGkdApp({ + id: 'com.example.app', // Android 包名 + name: '应用名称', + groups: [ + { + key: 0, + name: '分段广告-具体描述', // 必须以 categories.ts 中的分类名称开头 + activityIds: ['com.example.Activity'], // 可选:限制特定 Activity + rules: [ + { + key: 0, + name: '步骤 1 描述', + matches: '[选择器语法]', // GKD 选择器(类 CSS 语法) + snapshotUrls: ['https://i.gkd.li/i/...'], // 必填:用于维护的快照链接 + }, + ], + }, + ], +}); +``` + +### 选择器语法 + +GKD 选择器使用类 CSS 语法匹配 Android 视图节点。常用模式: +- `[text="精确文本"]` — 按文本内容匹配 +- `[text*="包含"]` — 子字符串匹配 +- `[id="com.example:id/btn"]` — 按资源 ID 匹配 +- `[vid="viewId"]` — 按视图 ID 匹配 +- `[clickable=true]` — 按属性匹配 +- `@Node > [text="Child"]` — 关系选择器(子节点、兄弟节点、父节点) +- `[visibleToUser=true]` — 可见性约束 +- 详见 [GKD API 文档](https://gkd.li/api/) 和 [选择器参考](./docs/Selectors.md) + +## PR 约束 + +PR 检查强制要求每次 PR **最多修改 1 个订阅源文件**(即仅允许修改一个 `src/apps/*.ts`、`src/categories.ts`、`src/globalGroups.ts` 或 `src/subscription.ts`)。 + +## CI 工作流规范 + +本项目包含 GitHub Actions 工作流,用于自动审核用户提交的 Issue 内容。以下为设计规范: + +### 核心架构:Orchestrator + Worker + +``` +GitHub Actions (.yml) = Orchestrator(编排器) +Python (scripts/python/) = Worker(分析器) +``` + +两者职责**严格分离**。 + +#### GitHub Actions 职责 + +- Workflow 触发与权限声明(`contents: read` + `issues: write`) +- Job / Step 编排与条件分支(if) +- 环境准备(checkout、setup-python、标签预创建) +- 标签操作(gh CLI) +- 评论操作(find-comment + create-or-update-comment) +- Issue 关闭 / 重新打开(gh CLI) +- 读取 Python 输出,决定执行哪些 Job + +**原则:GitHub Actions 能完成的事,不允许放进 Python。** + +#### Python 职责 + +- Markdown 文本解析与正则匹配 +- URL 提取与分类 +- HTTP 网络请求(HEAD / GET+Range) +- GKD 分享链接 → GH 附件 URL 转换 +- GitHub 附件 → GKD 代理链接转换 +- Markdown 评论内容生成 +- 结果输出到 GITHUB_OUTPUT + +**Python 禁止:** +- 调用 GitHub REST API +- 打标签 / 移除标签 +- 发表 / 更新评论 +- 关闭 / 打开 Issue +- 任何 GitHub 状态修改 + +### 关键设计决策 + +1. **Python 只运行一次** — 在 `analyze` Job 中执行一次,输出所有原子化布尔标志,各处理 Job 根据标志决定是否执行 +2. **Fail Fast 原则** — 网络检查遇到第一个 404 立即停止,不发后续请求 +3. **幂等性** — 每次触发都完全重跑全流程,保证最终状态一致 +4. **评论防刷屏** — 使用 `find-comment` 按场景独立标记查找已有评论,更新而非重复创建 +5. **多 Job 架构** — 每个 Job 对应一个明确的业务节点,而非线性流水线 +6. **标签预创建** — 在 `analyze` Job 中预创建所有所需标签,确保后续操作不会因标签不存在而失败 + +### Python 模块结构 + +``` +scripts/python/ + ├── core/ # 核心功能层 + │ ├── extractor.py # 链接提取与分类 + │ ├── checker.py # 网络检查 + │ ├── converter.py # 链接转换 + │ └── snapshot_parser.py # 快照解析 + ├── utils/ # 工具模块层 + │ ├── models.py # 数据结构定义 + │ ├── common.py # 通用工具函数 + │ └── utils.py # GITHUB_OUTPUT 工具 + ├── api/ # 高层 API 层 + │ └── link_checker.py # 可复用的链接检查器 + ├── entry/ # 入口脚本层 + │ └── check_issue.py # Issue 场景主入口 + ├── tests/ # 测试层 + │ ├── verify.py # 本地验证脚本 + │ └── test_scenarios.json # 测试场景配置 + ├── formatter.py # 评论格式化(跨层使用) + └── README.md # 模块说明文档 +``` + +**模块化要求:** +- 每个文件职责单一 +- 禁止互相重复代码 +- 禁止一个几百行的大脚本 +- 每个文件顶部说明用途 +- 每个函数必须有注释 + +## Python 脚本 + +`scripts/python/` 包含 GitHub Issue 自动化工具: +- `check_issue.py` — 分析 Issue 内容中的快照链接(缺失、不可访问、可转换) +- `snapshot_parser.py` — 解析快照节点树 +- `formatter.py` — 从快照数据格式化规则模板 +- `converter.py` — 将快照转换为 GKD 规则格式 + +## 构建输出 + +- `dist/gkd.json5` — GKD 应用消费的主订阅文件 +- `dist/README.md` — 自动生成的应用/规则数量摘要 +- `dist/gkd.version.json5` — 版本跟踪 +- `dist/CHANGELOG.md` — 自动生成的变更日志 +- 根目录 `README.md` 在构建时从 `Template.md` 重新生成,包含当前统计数据 From 31c0aca09f9b491190763693b7b18afd7cd51f5b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 04:11:49 +0800 Subject: [PATCH 33/90] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20Python?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=8C=89=E8=81=8C=E8=B4=A3?= =?UTF-8?q?=E5=88=86=E5=B1=82=E7=BB=84=E7=BB=87=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/core/checker.py | 171 +++++++++++++++++++++++++ scripts/python/core/converter.py | 89 +++++++++++++ scripts/python/core/extractor.py | 94 ++++++++++++++ scripts/python/core/snapshot_parser.py | 169 ++++++++++++++++++++++++ scripts/python/utils/__init__.py | 0 scripts/python/utils/common.py | 75 +++++++++++ scripts/python/utils/models.py | 139 ++++++++++++++++++++ scripts/python/utils/utils.py | 27 ++++ 8 files changed, 764 insertions(+) create mode 100644 scripts/python/core/checker.py create mode 100644 scripts/python/core/converter.py create mode 100644 scripts/python/core/extractor.py create mode 100644 scripts/python/core/snapshot_parser.py create mode 100644 scripts/python/utils/__init__.py create mode 100644 scripts/python/utils/common.py create mode 100644 scripts/python/utils/models.py create mode 100644 scripts/python/utils/utils.py diff --git a/scripts/python/core/checker.py b/scripts/python/core/checker.py new file mode 100644 index 000000000..120196c5c --- /dev/null +++ b/scripts/python/core/checker.py @@ -0,0 +1,171 @@ +""" +链接检查模块 + +负责两类检查: +1. 不可访问快照链接检查:识别 i.gkd.li/snapshot/ 链接 +2. 网络有效性检查:对链接发起 HTTP 请求,验证可访问性 + - GitHub 附件链接直接检查 + - GKD 分享链接先转换为 GH 附件 URL 再检查 + +本模块只返回检查结果,不做任何业务判断(如是否关闭 Issue)。 +""" + +import re + +from utils.models import LinkInfo, NetworkResult + + +# ── GKD 链接 → GH 附件 URL 转换 ── + +# 从 GKD 分享链接中提取数字 ID +_RE_GKD_ID = re.compile(r"https://i\.gkd\.li/i/(\d+)") + +# GH 附件 URL 模板:{id} 为 GKD 链接中的数字,file.zip 为固定占位符 +_GH_ATTACHMENT_TEMPLATE = "https://github.com/user-attachments/files/{id}/file.zip" + + +def gkd_to_gh_attachment_url(gkd_url: str) -> str | None: + """ + 将 GKD 分享链接转换为 GitHub 附件 URL,用于网络可访问性检查。 + + 例如:https://i.gkd.li/i/29722723 → https://github.com/user-attachments/files/29722723/file.zip + + 返回 None 表示 URL 不符合 GKD 分享链接格式。 + """ + match = _RE_GKD_ID.match(gkd_url) + if not match: + return None + return _GH_ATTACHMENT_TEMPLATE.format(id=match.group(1)) + + +# ── 不可访问快照链接检查 ── + + +def check_unreachable_links(links: list[LinkInfo]) -> list[LinkInfo]: + """ + 筛选出所有 i.gkd.li/snapshot/ 类型的不可访问链接。 + + 此类链接仅作者可访问,他人无法打开。 + """ + return [lnk for lnk in links if lnk.kind == "unreachable_snapshot"] + + +# ── 网络有效性检查 ── + + +def check_network_links(url: str, timeout: int = 20) -> NetworkResult: + """ + 对单个 URL 发起网络请求,验证其可访问性。 + + 请求策略(按优先级): + 1. HEAD 请求 —— 最快,只获取响应头 + 2. GET 请求 + Range 头 —— 只请求前 1 字节,兼容不支持 HEAD 的服务器 + + 返回值: + - status="ok":链接可正常访问 + - status="404":链接返回 404,确认不可访问 + - status="uncertain":返回 403/5xx 等不确定状态码 + """ + import urllib.request + import urllib.error + + result = _try_head_request(url, timeout) + if result is not None: + return result + + return _try_get_range_request(url, timeout) + + +def _try_head_request(url: str, timeout: int) -> NetworkResult | None: + """ + 发起 HEAD 请求。 + + 返回 None 表示服务器不支持 HEAD(如返回 405), + 需要回退到 GET 请求。 + """ + import urllib.request + import urllib.error + + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "GKD-Issue-Checker/1.0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return NetworkResult(status="ok", status_code=resp.status) + except urllib.error.HTTPError as e: + if e.code == 404: + return NetworkResult(status="404", status_code=404) + if e.code == 405: + return None + if e.code == 403: + return NetworkResult( + status="uncertain", + status_code=403, + detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + ) + if 500 <= e.code < 600: + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", + ) + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} {e.reason}", + ) + except Exception as e: + return NetworkResult( + status="uncertain", + status_code=0, + detail=f"请求异常: {type(e).__name__}: {e}", + ) + + +def _try_get_range_request(url: str, timeout: int) -> NetworkResult: + """ + 发起 GET 请求 + Range 头(只请求前 1 字节)。 + + 用于兼容不支持 HEAD 方法的服务器。 + """ + import urllib.request + import urllib.error + + try: + req = urllib.request.Request(url, method="GET") + req.add_header("User-Agent", "GKD-Issue-Checker/1.0") + req.add_header("Range", "bytes=0-0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + code = resp.status + if code in (200, 206): + return NetworkResult(status="ok", status_code=code) + return NetworkResult( + status="uncertain", + status_code=code, + detail=f"GET 请求返回非预期状态码: {code}", + ) + except urllib.error.HTTPError as e: + if e.code == 404: + return NetworkResult(status="404", status_code=404) + if e.code == 403: + return NetworkResult( + status="uncertain", + status_code=403, + detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + ) + if 500 <= e.code < 600: + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", + ) + return NetworkResult( + status="uncertain", + status_code=e.code, + detail=f"HTTP {e.code} {e.reason}", + ) + except Exception as e: + return NetworkResult( + status="uncertain", + status_code=0, + detail=f"请求异常: {type(e).__name__}: {e}", + ) \ No newline at end of file diff --git a/scripts/python/core/converter.py b/scripts/python/core/converter.py new file mode 100644 index 000000000..fdf8df01a --- /dev/null +++ b/scripts/python/core/converter.py @@ -0,0 +1,89 @@ +""" +链接转换模块 + +将 GitHub 附件链接转换为 GKD 代理链接。 +仅处理 kind == "github_attachment" 的链接,GKD 链接原样保留。 + +转换公式:https://i.gkd.li/i?url={{原始GitHub附件URL}} + +本模块只负责数据转换,不负责评论格式化(由 formatter.py 处理)。 +""" + +import re +from dataclasses import dataclass + +from utils.models import LinkInfo +from utils.common import extract_github_filename + + +# ── 数据结构 ── + + +@dataclass +class ConvertedLink: + """转换后的链接信息""" + + original_url: str # 原始 GitHub 附件 URL + converted_url: str # 转换后的 GKD 代理 URL + display_text: str # 原始 Markdown 链接的显示文字 + app_name: str # 从文件名提取的 App 名称(不匹配时为空) + activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) + timestamp: str # 从文件名提取的时间戳(不匹配时为空) + + +# ── 常量 ── + +# GKD 代理链接模板 +GKD_PROXY_TEMPLATE = "https://i.gkd.li/i?url={url}" + +# 文件名模式:{App}_{Activity}-{timestamp}.zip +_RE_NAME_PATTERN = re.compile( + r"^(?P.+?)_(?P.+?)-(?P\d+)\.zip$" +) + + +# ── 转换函数 ── + + +def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: + """ + 将 GitHub 附件链接转换为 GKD 代理链接。 + + 仅处理 kind == "github_attachment" 的链接。 + 文件名不符合 {App}_{Activity}-{timestamp}.zip 模式的, + app_name / activity_name / timestamp 设为空字符串。 + """ + results: list[ConvertedLink] = [] + for lnk in links: + if lnk.kind != "github_attachment": + continue + + # 执行 URL 转换 + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + + # 从 URL 中提取文件名 + filename = extract_github_filename(lnk.url) + + # 尝试解析文件名中的 App / Activity / timestamp + app_name = "" + activity_name = "" + timestamp = "" + if filename: + name_match = _RE_NAME_PATTERN.match(filename) + if name_match: + app_name = name_match.group("app") + activity_name = name_match.group("activity") + timestamp = name_match.group("timestamp") + + results.append( + ConvertedLink( + original_url=lnk.url, + converted_url=converted_url, + display_text=lnk.display_text, + app_name=app_name, + activity_name=activity_name, + timestamp=timestamp, + ) + ) + + return results \ No newline at end of file diff --git a/scripts/python/core/extractor.py b/scripts/python/core/extractor.py new file mode 100644 index 000000000..c8d1d0f0b --- /dev/null +++ b/scripts/python/core/extractor.py @@ -0,0 +1,94 @@ +""" +链接提取与分类模块 + +从 Issue Body 中提取所有快照相关链接,并分类为: +- gkd:GKD 分享链接 (https://i.gkd.li/i/XXXXXXXX) +- github_attachment:GitHub 附件链接 (github.com/user-attachments/files/) +- unreachable_snapshot:不可访问的快照链接 (i.gkd.li/snapshot/) + +本模块只负责提取和分类,不做任何检查或判断。 +""" + +import re + +from utils.models import LinkInfo + + +# ── 正则模式 ── + +# Markdown 格式链接:[显示文字](URL) +_RE_MD_LINK = re.compile(r"\[([^\]]*)\]\(([^)]+)\)") + +# GKD 分享链接:https://i.gkd.li/i/数字 +_RE_GKD_LINK = re.compile(r"https://i\.gkd\.li/i/\d+") + +# GitHub 附件链接:https://github.com/user-attachments/files/... +_RE_GITHUB_ATTACHMENT = re.compile( + r"https://github\.com/user-attachments/files/[^\s\)]+" +) + +# 不可访问的快照链接:https://i.gkd.li/snapshot/... +_RE_UNREACHABLE_SNAPSHOT = re.compile(r"https://i\.gkd\.li/snapshot/[^\s\)]*") + + +# ── 分类函数 ── + + +def _classify_url(url: str) -> str | None: + """ + 对单个 URL 进行分类。 + + 返回值: + - "gkd":GKD 分享链接 + - "github_attachment":GitHub 附件链接 + - "unreachable_snapshot":不可访问的快照链接 + - None:不属于以上任何类别(忽略) + """ + if _RE_UNREACHABLE_SNAPSHOT.match(url): + return "unreachable_snapshot" + if _RE_GKD_LINK.match(url): + return "gkd" + if _RE_GITHUB_ATTACHMENT.match(url): + return "github_attachment" + return None + + +# ── 主提取函数 ── + + +def extract_links(body: str) -> list[LinkInfo]: + """ + 从 Issue Body 中提取所有快照相关链接。 + + 处理两种格式: + 1. Markdown 链接:[文字](URL) → 保留显示文字 + 2. 纯文本 URL:直接匹配 → display_text 为空 + + 去重策略:同一 URL 只保留首次出现。 + """ + seen: set[str] = set() + results: list[LinkInfo] = [] + + # 先提取 Markdown 格式链接(优先保留显示文字) + for match in _RE_MD_LINK.finditer(body): + display_text = match.group(1) + url = match.group(2) + kind = _classify_url(url) + if kind and url not in seen: + seen.add(url) + results.append(LinkInfo(url=url, kind=kind, display_text=display_text)) + + # 再提取纯文本 URL(排除已被 Markdown 链接捕获的) + all_url_patterns = [ + (_RE_UNREACHABLE_SNAPSHOT, "unreachable_snapshot"), + (_RE_GKD_LINK, "gkd"), + (_RE_GITHUB_ATTACHMENT, "github_attachment"), + ] + for pattern, kind in all_url_patterns: + for match in pattern.finditer(body): + url = match.group(0) + if url not in seen: + seen.add(url) + results.append(LinkInfo(url=url, kind=kind, display_text="")) + + return results \ No newline at end of file diff --git a/scripts/python/core/snapshot_parser.py b/scripts/python/core/snapshot_parser.py new file mode 100644 index 000000000..741f67bc7 --- /dev/null +++ b/scripts/python/core/snapshot_parser.py @@ -0,0 +1,169 @@ +""" +快照解析模块 + +负责下载 zip 压缩包、提取 snapshot.json、解析为结构化数据。 +本模块只负责数据解析,不负责评论格式化(由 formatter.py 处理)。 + +解析策略: +- 下载 zip 到内存,不解压到磁盘 +- 从 zip 中查找 snapshot.json(兼容不同目录层级) +- 兼容精简模式(顶层字段)和完整模式(appInfo/gkdAppInfo 对象) +- 缺失字段使用合理默认值 +""" + +import io +import json +import zipfile + +import urllib.request +import urllib.error + +from utils.models import SnapshotInfo + + +# ── 下载与解析 ── + + +def download_and_parse(url: str, converted_url: str = "", timeout: int = 30) -> SnapshotInfo | None: + """ + 下载 zip 并解析快照信息。 + + 参数: + - url:zip 文件的下载地址 + - converted_url:转换后的 GKD 代理链接(用于 Bot 评论展示) + - timeout:下载超时时间(秒) + + 返回 SnapshotInfo,下载或解析失败时返回 None。 + """ + zip_data = _download_zip(url, timeout) + if not zip_data: + return None + + snapshot_json = _extract_snapshot_json(zip_data) + if not snapshot_json: + return None + + return _parse_snapshot(snapshot_json, url, converted_url) + + +# ── 内部函数 ── + + +def _download_zip(url: str, timeout: int) -> bytes | None: + """ + 下载 zip 文件到内存。 + + 返回 zip 的字节数据,失败时返回 None。 + """ + try: + req = urllib.request.Request(url, method="GET") + req.add_header("User-Agent", "GKD-Issue-Checker/1.0") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + except Exception: + return None + + +def _extract_snapshot_json(zip_data: bytes) -> dict | None: + """ + 从 zip 字节数据中提取 snapshot.json 的内容。 + + 查找 zip 内所有 .json 文件,优先选择名为 snapshot.json 的。 + 兼容不同目录层级(根目录或子目录)。 + """ + try: + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + # 优先查找 snapshot.json + for name in zf.namelist(): + if name.endswith("snapshot.json"): + with zf.open(name) as f: + return json.loads(f.read().decode("utf-8")) + + # 回退:查找任意 .json 文件 + for name in zf.namelist(): + if name.endswith(".json"): + with zf.open(name) as f: + return json.loads(f.read().decode("utf-8")) + except Exception: + pass + + return None + + +def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> SnapshotInfo: + """ + 将 snapshot.json 解析为 SnapshotInfo。 + + 兼容精简模式(顶层 appName 等字段)和完整模式(appInfo 对象)。 + 缺失字段使用合理默认值。 + """ + # 应用信息:优先完整模式 appInfo,回退精简模式顶层字段 + app_info = data.get("appInfo", {}) or {} + app_name = app_info.get("name") or data.get("appName", "") + app_version_name = str(app_info.get("versionName") or data.get("appVersionName", "")) + app_version_code = str(app_info.get("versionCode") or data.get("appVersionCode", "")) + + # GKD 信息:优先 gkdAppInfo,回退顶层字段 + gkd_info = data.get("gkdAppInfo", {}) or {} + gkd_version_name = str(gkd_info.get("versionName") or data.get("gkdVersionName", "")) + gkd_version_code = str(gkd_info.get("versionCode") or data.get("gkdVersionCode", "")) + gkd_user_id = str(gkd_info.get("userId", "")) + + # 设备信息 + device = data.get("device", {}) or {} + + # 节点统计 + nodes = data.get("nodes", []) or [] + total_nodes = len(nodes) + visible_nodes = 0 + clickable_nodes = 0 + max_depth = 0 + id_qf_count = 0 + text_qf_count = 0 + + for node in nodes: + attr = node.get("attr", {}) or {} + + if attr.get("visibleToUser", False): + visible_nodes += 1 + if attr.get("clickable", False): + clickable_nodes += 1 + + depth = attr.get("depth", 0) + if depth > max_depth: + max_depth = depth + + # idQf / textQf 缺失视为 null,仅 true 时计数 + if node.get("idQf") is True: + id_qf_count += 1 + if node.get("textQf") is True: + text_qf_count += 1 + + return SnapshotInfo( + app_name=app_name, + app_id=data.get("appId", ""), + app_version_name=app_version_name, + app_version_code=app_version_code, + activity_id=data.get("activityId", ""), + snapshot_id=str(data.get("id", "")), + screen_width=data.get("screenWidth", 0), + screen_height=data.get("screenHeight", 0), + is_landscape=data.get("isLandscape", False), + gkd_version_name=gkd_version_name, + gkd_version_code=gkd_version_code, + gkd_user_id=gkd_user_id, + device_code=device.get("device", ""), + device_model=device.get("model", ""), + device_manufacturer=device.get("manufacturer", ""), + device_brand=device.get("brand", ""), + device_sdk=device.get("sdkInt", 0), + device_release=device.get("release", ""), + total_nodes=total_nodes, + visible_nodes=visible_nodes, + clickable_nodes=clickable_nodes, + max_depth=max_depth, + id_qf_count=id_qf_count, + text_qf_count=text_qf_count, + original_url=original_url, + converted_url=converted_url, + ) \ No newline at end of file diff --git a/scripts/python/utils/__init__.py b/scripts/python/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/python/utils/common.py b/scripts/python/utils/common.py new file mode 100644 index 000000000..3775ffe8d --- /dev/null +++ b/scripts/python/utils/common.py @@ -0,0 +1,75 @@ +""" +通用工具函数模块 + +提供跨模块复用的工具函数,消除重复代码。 +各模块从本模块导入所需函数,确保实现一致性。 +""" + +import re + + +# ── URL 处理函数 ── + + +# GitHub 附件 URL 正则:提取文件名部分 +_RE_GITHUB_FILENAME = re.compile(r"https://github\.com/user-attachments/files/\d+/(.+)") + + +def extract_filename(url: str) -> str: + """ + 从 URL 中提取文件名。 + + 参数: + url: 完整的 URL 地址 + + 返回: + 文件名字符串,如 "app.zip" + + 示例: + >>> extract_filename("https://example.com/path/to/file.zip") + "file.zip" + """ + return url.rsplit("/", 1)[-1] if "/" in url else url + + +def extract_github_filename(url: str) -> str: + """ + 从 GitHub 附件 URL 中提取文件名。 + + 专门处理 GitHub 附件链接格式: + https://github.com/user-attachments/files/{id}/{filename} + + 参数: + url: GitHub 附件 URL + + 返回: + 文件名字符串 + + 示例: + >>> extract_github_filename("https://github.com/user-attachments/files/12345/app.zip") + "app.zip" + """ + match = _RE_GITHUB_FILENAME.match(url) + return match.group(1) if match else extract_filename(url) + + +# ── Activity 名称处理 ── + + +def short_activity_name(activity_id: str) -> str: + """ + Activity 类名取最后一段(类名简写)。 + + 参数: + activity_id: 完整的 Activity 类名,如 "com.example.MyActivity" + + 返回: + 简写形式,如 "MyActivity" + + 示例: + >>> short_activity_name("com.mihoyo.cloudgame.main.MiHoYoCloudMainActivity") + "MiHoYoCloudMainActivity" + """ + if "." in activity_id: + return activity_id.rsplit(".", 1)[-1] + return activity_id diff --git a/scripts/python/utils/models.py b/scripts/python/utils/models.py new file mode 100644 index 000000000..5c0581e06 --- /dev/null +++ b/scripts/python/utils/models.py @@ -0,0 +1,139 @@ +""" +统一数据结构定义模块 + +集中定义所有模块共享的数据结构(dataclass)。 +各模块从本模块导入,避免重复定义,确保数据结构一致性。 +""" + +from dataclasses import dataclass, field + + +# ── 链接相关数据结构 ── + + +@dataclass +class LinkInfo: + """ + 提取出的单条链接信息 + + 由 extractor.py 的 extract_links() 函数返回。 + """ + + url: str # 完整 URL + kind: str # 分类:gkd / github_attachment / unreachable_snapshot + display_text: str # Markdown 链接的显示文字,纯文本时为空 + + +@dataclass +class NetworkResult: + """ + 网络请求检查结果 + + 由 checker.py 的 check_network_links() 函数返回。 + """ + + status: str # "ok" / "404" / "uncertain" / "skipped" + status_code: int = 0 # HTTP 状态码 + detail: str = "" # 错误详情(供折叠展示) + + +@dataclass +class ConvertedLink: + """ + 转换后的链接信息 + + 由 converter.py 的 convert_github_attachments() 函数返回。 + """ + + original_url: str # 原始 GitHub 附件 URL + converted_url: str # 转换后的 GKD 代理 URL + display_text: str # 原始 Markdown 链接的显示文字 + app_name: str # 从文件名提取的 App 名称(不匹配时为空) + activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) + timestamp: str # 从文件名提取的时间戳(不匹配时为空) + + +# ── 快照相关数据结构 ── + + +@dataclass +class SnapshotInfo: + """ + 快照解析后的结构化信息 + + 由 snapshot_parser.py 的 download_and_parse() 函数返回。 + 包含应用信息、界面信息、设备信息、节点统计等完整快照数据。 + """ + + # 应用信息 + app_name: str + app_id: str + app_version_name: str + app_version_code: str + + # 界面信息 + activity_id: str + snapshot_id: str + + # 屏幕信息 + screen_width: int + screen_height: int + is_landscape: bool + + # GKD 信息 + gkd_version_name: str + gkd_version_code: str + gkd_user_id: str + + # 设备信息 + device_code: str + device_model: str + device_manufacturer: str + device_brand: str + device_sdk: int + device_release: str + + # 节点统计 + total_nodes: int + visible_nodes: int + clickable_nodes: int + max_depth: int + id_qf_count: int + text_qf_count: int + + # 链接 + original_url: str + converted_url: str + + +# ── 检查报告数据结构 ── + + +@dataclass +class LinkCheckResult: + """ + 单个链接的检查结果 + + 包含原始链接信息、网络检查结果、转换后的 URL、解析的快照信息。 + """ + + link: LinkInfo + network_result: NetworkResult + converted_url: str = "" + snapshot: SnapshotInfo | None = None + + +@dataclass +class CheckReport: + """ + 链接检查报告 + + 由 link_checker.py 的 LinkChecker.extract_and_check() 方法返回。 + 包含统计信息和详细的检查结果列表。 + """ + + total_links: int # 总链接数 + ok_count: int # 可访问链接数 + fail_count: int # 404 失败链接数 + uncertain_count: int # 不确定链接数(403/5xx) + links: list = field(default_factory=list) # list[LinkCheckResult] diff --git a/scripts/python/utils/utils.py b/scripts/python/utils/utils.py new file mode 100644 index 000000000..93e116c1e --- /dev/null +++ b/scripts/python/utils/utils.py @@ -0,0 +1,27 @@ +""" +公共工具模块 + +提供 GITHUB_OUTPUT 写入等共享工具函数,供其他模块调用。 +本模块不包含任何业务逻辑。 +""" + +import os + + +# ── 本工作流管理的所有标签 ── + +MANAGED_LABELS = [ + "缺失快照(missing-snapshot)", + "需补充链接(need-supplement-link)", + "链接无法访问(inaccessible-link)", +] + + +def write_output(key: str, value: str): + """ + 向 GITHUB_OUTPUT 写入一个键值对。 + + 使用 heredoc 语法支持多行值,确保 Markdown 内容正确传递。 + """ + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"{key}< Date: Sat, 11 Jul 2026 04:11:56 +0800 Subject: [PATCH 34/90] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E8=84=9A=E6=9C=AC=E5=92=8C=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=9A=84=E5=AF=BC=E5=85=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/entry/check_issue.py | 304 ++++++++++++++++++++++++++++ scripts/python/formatter.py | 23 +-- 2 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 scripts/python/entry/check_issue.py diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py new file mode 100644 index 000000000..b8930a193 --- /dev/null +++ b/scripts/python/entry/check_issue.py @@ -0,0 +1,304 @@ +""" +Issue 快照链接检查主入口 + +职责:协调各模块执行分析流程,将原子化结果输出到 GITHUB_OUTPUT。 +不直接操作 GitHub API —— 所有 GitHub 操作由 YAML 工作流完成。 + +流程: + 1. 提取链接 → 判断是否缺少快照(唯一致命,关闭 Issue) + 2. 检查不可访问快照链接(i.gkd.li/snapshot/,非致命) + 3. 网络有效性检查(GKD 链接先转 GH 附件 URL 再检查 / GH 附件直接检查) + - 404 非致命,打标签+评论但不关闭 + - 403/5xx 不确定,打标签+评论 + 4. 链接转换 + Bot 评论生成(仅当含有 GitHub 附件链接时) + 5. 编辑/评论恢复判断 + +输出变量(原子化标志,供 YAML 多 Job 条件判断): + - has_snapshot : 是否包含任何快照链接 + - has_unreachable : 是否包含不可访问快照 + - network_status : 网络检查结果 (ok / 404 / uncertain / skipped) + - network_detail : 网络错误详情 + - has_convertible : 是否有可转换的 GitHub 附件 + - warning_type : 警告类型 (missing / unreachable / inaccessible / uncertain / recovery / "") + - comment_missing : 缺失快照评论(含 标记) + - comment_unreachable : 不可访问快照评论(含 标记) + - comment_404 : 链接404评论(含 标记) + - comment_uncertain : 网络不确定评论(含 标记) + - comment_recovery : 恢复评论(含 标记) + - comment_bot : Bot 评论(含 标记) +""" + +import os +from dataclasses import dataclass + +from utils.models import LinkInfo, SnapshotInfo +from utils.common import extract_filename +from core.extractor import extract_links +from core.checker import ( + check_unreachable_links, + check_network_links, + gkd_to_gh_attachment_url, +) +from core.converter import convert_github_attachments, GKD_PROXY_TEMPLATE +from core.snapshot_parser import download_and_parse +from formatter import ( + build_warning_missing, + build_warning_unreachable, + build_warning_inaccessible, + build_warning_uncertain, + build_recovery_comment, + build_bot_comment, +) +from utils.utils import write_output + + +# ── 快照相关链接类型集合 ── + +_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} + + +# ── 网络检查聚合结果 ── + + +@dataclass +class _NetworkCheckResult: + """网络检查聚合结果,记录首次遇到的致命/不确定错误""" + + status: str = "skipped" # 初始状态为 skipped,只有实际检查后才变为 ok/404/uncertain + detail: str = "" + fail_url: str = "" + uncertain_url: str = "" + uncertain_code: int = 0 + uncertain_detail: str = "" + + +# ── 主流程 ── + + +def main(): + body = os.environ.get("ISSUE_BODY", "") or "" + comment_body = os.environ.get("ISSUE_COMMENT_BODY", "") or "" + issue_user = os.environ.get("ISSUE_USER", "") + issue_action = os.environ.get("ISSUE_ACTION", "") + + # 合并 Issue Body 和评论内容一起分析(评论补充的链接也参与检查) + full_text = body + "\n" + comment_body if comment_body else body + + has_snapshot = "true" + has_unreachable = "false" + network_status = "skipped" + network_detail = "" + has_convertible = "false" + warning_type = "" + comment_missing = "" + comment_unreachable = "" + comment_404 = "" + comment_uncertain = "" + comment_recovery = "" + comment_bot = "" + + # ── 第一步:提取所有链接 ── + links = extract_links(full_text) + + # ── 第二步:判断是否缺少快照(唯一致命 → 提前返回) ── + has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) + + if not has_any_snapshot: + comment_missing = build_warning_missing(issue_user) + _output( + has_snapshot="false", + has_unreachable="false", + network_status="skipped", + network_detail="", + has_convertible="false", + warning_type="missing", + comment_missing=comment_missing, + comment_unreachable="", + comment_404="", + comment_uncertain="", + comment_recovery="", + comment_bot="", + ) + return + + # ── 第三步:检查不可访问快照链接(非致命,继续后续检查) ── + unreachable_links = check_unreachable_links(links) + has_unreachable = "true" if unreachable_links else "false" + if unreachable_links: + comment_unreachable = build_warning_unreachable(issue_user) + + # ── 第四步:网络有效性检查 ── + # GKD 分享链接先转换为 GH 附件 URL 再检查,GH 附件链接直接检查 + net_result = _check_all_links(links) + network_status = net_result.status + network_detail = net_result.detail + + if network_status == "404": + comment_404 = build_warning_inaccessible(issue_user, net_result.fail_url) + + if network_status == "uncertain": + comment_uncertain = build_warning_uncertain( + issue_user, + net_result.uncertain_url, + net_result.uncertain_code, + net_result.uncertain_detail, + ) + + # ── 第五步:链接转换 + 快照解析 + Bot 评论生成 ── + # 仅当网络检查通过(ok/skipped)且含有可下载的快照链接时执行 + # 如果网络检查失败(404/uncertain),不生成 Bot 评论 + if network_status in ("ok", "skipped"): + snapshots, gkd_links = _parse_all_snapshots(links) + if snapshots or gkd_links: + has_convertible = "true" + comment_body = build_bot_comment(snapshots, gkd_links) + comment_bot = "\n" + comment_body + + # ── 第六步:编辑/评论恢复判断 ── + # 当 edited 或 issue_comment 触发且所有检查均通过时,触发恢复流程 + # 恢复条件:edited/comment + 至少有一个有效快照链接 + 网络OK + # 不要求旧问题链接消失——作者补充有效链接即可恢复 + has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in links) + + if issue_action in ("edited", "comment") and has_valid_snapshot and network_status in ("ok", "skipped"): + warning_type = "recovery" + comment_recovery = build_recovery_comment(issue_user) + + _output( + has_snapshot=has_snapshot, + has_unreachable=has_unreachable, + network_status=network_status, + network_detail=network_detail, + has_convertible=has_convertible, + warning_type=warning_type, + comment_missing=comment_missing, + comment_unreachable=comment_unreachable, + comment_404=comment_404, + comment_uncertain=comment_uncertain, + comment_recovery=comment_recovery, + comment_bot=comment_bot, + ) + + +def _check_all_links(links: list) -> _NetworkCheckResult: + """ + 对所有可检查链接执行网络有效性检查。 + + 检查对象: + - GitHub 附件链接:直接检查原始 URL + - GKD 分享链接:先转换为 GH 附件 URL 再检查 + + 遵循 Fail Fast 原则:遇到 404 立即返回。 + 不确定结果(403/5xx)为非致命,记录但不中断。 + + 返回:_NetworkCheckResult 聚合结果 + """ + result = _NetworkCheckResult() + + for lnk in links: + if lnk.kind == "github_attachment": + check_url = lnk.url + elif lnk.kind == "gkd": + check_url = gkd_to_gh_attachment_url(lnk.url) + if not check_url: + continue + else: + continue + + check = check_network_links(check_url) + + if check.status == "404": + result.status = "404" + result.fail_url = lnk.url + return result + + if check.status == "ok": + # 检查成功,更新状态为 ok(只有首次成功时更新) + if result.status == "skipped": + result.status = "ok" + + if check.status == "uncertain" and result.status != "uncertain": + result.status = "uncertain" + result.detail = f"HTTP {check.status_code}: {check.detail}" + result.uncertain_url = lnk.url + result.uncertain_code = check.status_code + result.uncertain_detail = check.detail + + return result + + +def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: + """ + 下载并解析所有快照链接,同 Activity 只下载一个代表。 + + 返回: + - snapshots:解析成功的 SnapshotInfo 列表 + - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] + """ + from converter import GKD_PROXY_TEMPLATE + + snapshots: list[SnapshotInfo] = [] + gkd_links: list[tuple[str, str]] = [] + + # 已下载的 Activity 集合,用于去重 + seen_activities: set[str] = set() + + # 先处理 GitHub 附件链接 + for lnk in links: + if lnk.kind != "github_attachment": + continue + + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + snap = download_and_parse(lnk.url, converted_url) + + if snap is None: + # 下载失败,仍作为可转换链接保留 + gkd_links.append((lnk.display_text or _extract_filename(lnk.url), converted_url)) + continue + + act_key = f"{snap.app_id}|{snap.activity_id}" + if act_key in seen_activities: + # 同 Activity 已有代表,只记录链接 + gkd_links.append((snap.snapshot_id or _extract_filename(lnk.url), converted_url)) + else: + seen_activities.add(act_key) + snapshots.append(snap) + + # 再处理 GKD 分享链接(GKD 链接原样保留,不套代理模板) + for lnk in links: + if lnk.kind != "gkd": + continue + + gh_url = gkd_to_gh_attachment_url(lnk.url) + if not gh_url: + continue + + snap = download_and_parse(gh_url, lnk.url) + + if snap is None: + gkd_links.append((lnk.display_text or lnk.url, lnk.url)) + continue + + act_key = f"{snap.app_id}|{snap.activity_id}" + if act_key in seen_activities: + gkd_links.append((snap.snapshot_id or lnk.url, lnk.url)) + else: + seen_activities.add(act_key) + snapshots.append(snap) + + return snapshots, gkd_links + + +def _extract_filename(url: str) -> str: + """从 URL 中提取文件名""" + return extract_filename(url) + + +def _output(**kwargs): + """将所有分析结果写入 GITHUB_OUTPUT。""" + for key, value in kwargs.items(): + write_output(key, value) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index 3a4abf1a0..6e822aba6 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -10,7 +10,8 @@ 本模块只负责内容生成,不负责评论发布(由 YAML 工作流完成)。 """ -from snapshot_parser import SnapshotInfo +from utils.models import SnapshotInfo +from utils.common import extract_filename, short_activity_name # ── 警告评论生成 ── @@ -203,7 +204,7 @@ def _render_activity_line(lines: list[str], snap: SnapshotInfo): 格式:**Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 """ # Activity 名称取最后一段(类名简写) - act_display = _short_activity_name(snap.activity_id) + act_display = short_activity_name(snap.activity_id) # 统计信息行 stats = ( @@ -216,7 +217,7 @@ def _render_activity_line(lines: list[str], snap: SnapshotInfo): # 链接行 link_url = snap.converted_url or snap.original_url - link_display = snap.snapshot_id or _extract_filename(snap.original_url) + link_display = snap.snapshot_id or extract_filename(snap.original_url) lines.append(f"[{link_display}]({link_url})") lines.append("") @@ -256,7 +257,7 @@ def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: orientation = "横屏" if snap.is_landscape else "竖屏" resolution = f"{snap.screen_width}×{snap.screen_height}" gkd_info = f"{snap.gkd_version_name} ({snap.gkd_version_code})" if snap.gkd_version_name else "" - act_display = _short_activity_name(snap.activity_id) + act_display = short_activity_name(snap.activity_id) lines.append( f"| {act_display} | {snap.visible_nodes} | {resolution} | {orientation} " f"| {snap.app_version_code} | {gkd_info} | {snap.gkd_user_id} |" @@ -309,17 +310,3 @@ def _deduplicate_devices(snapshots: list[SnapshotInfo]) -> list[dict]: # ── 工具函数 ── -def _short_activity_name(activity_id: str) -> str: - """ - Activity 类名取最后一段。 - - 例如:com.mihoyo.cloudgame.main.MiHoYoCloudMainActivity → MiHoYoCloudMainActivity - """ - if "." in activity_id: - return activity_id.rsplit(".", 1)[-1] - return activity_id - - -def _extract_filename(url: str) -> str: - """从 URL 中提取文件名""" - return url.rsplit("/", 1)[-1] if "/" in url else url \ No newline at end of file From 659787fb704c7356afd25168897fdebd85f1676c Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 04:12:03 +0800 Subject: [PATCH 35/90] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8F=AF?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E9=93=BE=E6=8E=A5=E6=A3=80=E6=9F=A5=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/api/link_checker.py | 239 +++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 scripts/python/api/link_checker.py diff --git a/scripts/python/api/link_checker.py b/scripts/python/api/link_checker.py new file mode 100644 index 000000000..48a978492 --- /dev/null +++ b/scripts/python/api/link_checker.py @@ -0,0 +1,239 @@ +""" +高层链接检查器模块 + +提供可复用的通用 API,可在任何 CI 场景中使用。 + +本模块封装了底层的链接提取、网络检查、快照解析等功能, +提供简洁的高层接口,隐藏实现细节。 + +使用示例: + from link_checker import LinkChecker + + # 创建检查器实例 + checker = LinkChecker() + + # 从文本提取链接并检查 + report = checker.extract_and_check(text) + print(f"检查完成: {report.ok_count} 成功, {report.fail_count} 失败") + + # 批量检查 URL + results = checker.check_urls(["https://example.com/1.zip", "https://example.com/2.zip"]) + for r in results: + print(f"{r.link.url}: {r.network_result.status}") +""" + +from utils.models import ( + LinkInfo, + NetworkResult, + SnapshotInfo, + CheckReport, + LinkCheckResult, +) +from core.extractor import extract_links +from core.checker import check_network_links, gkd_to_gh_attachment_url +from core.converter import GKD_PROXY_TEMPLATE +from core.snapshot_parser import download_and_parse + + +# ── 快照相关链接类型集合 ── + +_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} + + +class LinkChecker: + """ + 通用链接检查器 + + 可在任何 CI 场景中复用,不绑定特定业务逻辑。 + 封装了底层模块的复杂性,提供简洁的高层接口。 + """ + + def __init__(self, timeout: int = 20): + """ + 初始化链接检查器。 + + 参数: + timeout: 网络请求超时时间(秒),默认 20 秒 + """ + self.timeout = timeout + + def extract_links(self, text: str) -> list[LinkInfo]: + """ + 从文本中提取所有快照相关链接。 + + 支持的链接类型: + - GKD 分享链接:https://i.gkd.li/i/数字 + - GitHub 附件链接:https://github.com/user-attachments/files/... + - 不可访问快照链接:https://i.gkd.li/snapshot/... + + 参数: + text: 包含链接的文本内容 + + 返回: + LinkInfo 列表,包含提取出的所有链接 + """ + return extract_links(text) + + def check_url(self, url: str) -> NetworkResult: + """ + 检查单个 URL 的可访问性。 + + 请求策略: + 1. HEAD 请求 —— 最快,只获取响应头 + 2. GET 请求 + Range 头 —— 只请求前 1 字节,兼容不支持 HEAD 的服务器 + + 参数: + url: 要检查的 URL + + 返回: + NetworkResult 检查结果 + """ + return check_network_links(url, self.timeout) + + def check_urls(self, urls: list[str]) -> list[LinkCheckResult]: + """ + 批量检查多个 URL 的可访问性。 + + 参数: + urls: URL 列表 + + 返回: + LinkCheckResult 列表,每个元素包含链接信息和检查结果 + """ + results = [] + for url in urls: + # 创建 LinkInfo 对象 + link = LinkInfo(url=url, kind="unknown", display_text="") + + # 执行网络检查 + net_result = self.check_url(url) + + results.append(LinkCheckResult(link=link, network_result=net_result)) + return results + + def extract_and_check(self, text: str) -> CheckReport: + """ + 从文本提取链接并检查可访问性(完整流程)。 + + 这是最常用的方法,执行完整的链接检查流程: + 1. 从文本中提取所有链接 + 2. 对每个链接执行网络可访问性检查 + 3. 尝试下载并解析快照(如果可能) + 4. 汇总统计结果 + + 参数: + text: 包含链接的文本内容 + + 返回: + CheckReport 检查报告,包含统计信息和详细结果 + """ + # 提取所有链接 + links = self.extract_links(text) + results = [] + + for link in links: + # 根据链接类型确定检查 URL + check_url = self._get_check_url(link) + if not check_url: + # 无法检查的链接类型,跳过网络检查 + results.append(LinkCheckResult( + link=link, + network_result=NetworkResult(status="skipped"), + )) + continue + + # 执行网络检查 + net_result = self.check_url(check_url) + + # 尝试下载解析快照(可选) + snapshot = None + if link.kind in ("github_attachment", "gkd"): + snapshot = self._try_parse_snapshot(link, check_url) + + results.append(LinkCheckResult( + link=link, + network_result=net_result, + converted_url=check_url, + snapshot=snapshot, + )) + + # 统计结果 + ok_count = sum(1 for r in results if r.network_result.status == "ok") + fail_count = sum(1 for r in results if r.network_result.status == "404") + uncertain_count = sum(1 for r in results if r.network_result.status == "uncertain") + + return CheckReport( + total_links=len(results), + ok_count=ok_count, + fail_count=fail_count, + uncertain_count=uncertain_count, + links=results, + ) + + def _get_check_url(self, link: LinkInfo) -> str | None: + """ + 根据链接类型确定用于检查的 URL。 + + - github_attachment:直接使用原始 URL + - gkd:转换为 GitHub 附件 URL + - 其他类型:返回 None(不检查) + + 参数: + link: 链接信息 + + 返回: + 用于检查的 URL,或 None + """ + if link.kind == "github_attachment": + return link.url + elif link.kind == "gkd": + return gkd_to_gh_attachment_url(link.url) + else: + return None + + def _try_parse_snapshot(self, link: LinkInfo, check_url: str) -> SnapshotInfo | None: + """ + 尝试下载并解析快照。 + + 参数: + link: 原始链接信息 + check_url: 用于下载的 URL + + 返回: + SnapshotInfo 或 None(下载/解析失败时) + """ + # 确定转换后的 URL(用于 Bot 评论展示) + if link.kind == "github_attachment": + converted_url = GKD_PROXY_TEMPLATE.format(url=link.url) + else: + converted_url = link.url + + # 尝试下载解析 + return download_and_parse(check_url, converted_url, self.timeout) + + +# ── 便捷函数 ── + + +def check_links_in_text(text: str, timeout: int = 20) -> CheckReport: + """ + 便捷函数:从文本提取链接并检查可访问性。 + + 等同于创建 LinkChecker 实例并调用 extract_and_check()。 + + 参数: + text: 包含链接的文本内容 + timeout: 网络请求超时时间(秒) + + 返回: + CheckReport 检查报告 + + 示例: + from link_checker import check_links_in_text + + report = check_links_in_text(issue_body) + if report.fail_count > 0: + print("发现不可访问的链接") + """ + checker = LinkChecker(timeout=timeout) + return checker.extract_and_check(text) From 7d4349c148ecf65cde14f624e12a1729f71603c8 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 04:12:11 +0800 Subject: [PATCH 36/90] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=AA=8C=E8=AF=81=E5=B7=A5=E5=85=B7=E5=92=8C=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/README.md | 118 +++++++++ scripts/python/api/__init__.py | 0 scripts/python/core/__init__.py | 0 scripts/python/entry/__init__.py | 0 scripts/python/tests/__init__.py | 0 scripts/python/tests/test_results.json | 277 +++++++++++++++++++++ scripts/python/tests/test_scenarios.json | 174 +++++++++++++ scripts/python/tests/verify.py | 297 +++++++++++++++++++++++ 8 files changed, 866 insertions(+) create mode 100644 scripts/python/README.md create mode 100644 scripts/python/api/__init__.py create mode 100644 scripts/python/core/__init__.py create mode 100644 scripts/python/entry/__init__.py create mode 100644 scripts/python/tests/__init__.py create mode 100644 scripts/python/tests/test_results.json create mode 100644 scripts/python/tests/test_scenarios.json create mode 100644 scripts/python/tests/verify.py diff --git a/scripts/python/README.md b/scripts/python/README.md new file mode 100644 index 000000000..0a531d00b --- /dev/null +++ b/scripts/python/README.md @@ -0,0 +1,118 @@ +# Python 脚本模块说明 + +本目录包含 GitHub Issue 自动化处理的 Python 脚本。 + +## 目录结构 + +``` +scripts/python/ +├── core/ # 核心功能层 +│ ├── extractor.py # 链接提取与分类 +│ ├── checker.py # 网络检查 +│ ├── converter.py # 链接转换 +│ └── snapshot_parser.py # 快照解析 +├── utils/ # 工具模块层 +│ ├── models.py # 数据结构定义 +│ ├── common.py # 通用工具函数 +│ └── utils.py # GITHUB_OUTPUT 工具 +├── api/ # 高层 API 层 +│ └── link_checker.py # 可复用的链接检查器 +├── entry/ # 入口脚本层 +│ └── check_issue.py # Issue 场景主入口 +├── tests/ # 测试层 +│ ├── verify.py # 本地验证脚本 +│ └── test_scenarios.json # 测试场景配置 +├── formatter.py # 评论格式化(跨层使用) +└── README.md # 本文件 +``` + +## 模块职责 + +### core/ - 核心功能层 + +| 文件 | 职责 | 主要函数 | +|------|------|---------| +| `extractor.py` | 从文本提取链接 | `extract_links(text)` | +| `checker.py` | 检查链接可访问性 | `check_network_links(url)`, `gkd_to_gh_attachment_url(url)` | +| `converter.py` | 链接格式转换 | `convert_github_attachments(links)` | +| `snapshot_parser.py` | 下载解析快照zip | `download_and_parse(url)` | + +### utils/ - 工具模块层 + +| 文件 | 职责 | 主要函数/类 | +|------|------|------------| +| `models.py` | 数据结构定义 | `LinkInfo`, `NetworkResult`, `SnapshotInfo`, `CheckReport` | +| `common.py` | 通用工具函数 | `extract_filename()`, `short_activity_name()` | +| `utils.py` | 工具函数 | `write_output()` | + +### api/ - 高层 API 层 + +| 文件 | 职责 | 主要函数/类 | +|------|------|------------| +| `link_checker.py` | 可复用的链接检查器 | `LinkChecker` 类, `check_links_in_text()` | + +### entry/ - 入口脚本层 + +| 文件 | 职责 | 主要函数 | +|------|------|---------| +| `check_issue.py` | Issue 场景主入口 | `main()` | + +### tests/ - 测试层 + +| 文件 | 职责 | +|------|------| +| `verify.py` | 本地验证脚本 | +| `test_scenarios.json` | 测试场景配置 | + +## 使用方式 + +### 1. 在其他 CI 中复用(推荐) + +```python +from api.link_checker import LinkChecker, check_links_in_text + +# 方式1:使用类 +checker = LinkChecker(timeout=20) +report = checker.extract_and_check(text) +print(f"检查完成: {report.ok_count} 成功, {report.fail_count} 失败") + +# 方式2:使用便捷函数 +report = check_links_in_text(text) +``` + +### 2. Issue 场景专用 + +```bash +cd scripts/python +export ISSUE_BODY="..." +export ISSUE_USER="testuser" +export ISSUE_ACTION="opened" +python entry/check_issue.py +``` + +## 本地验证 + +修改 Python 脚本后,运行验证确保功能正常: + +```bash +cd scripts/python +python tests/verify.py +``` + +## 依赖关系 + +``` +utils/models.py (无依赖) +utils/common.py (无依赖) +utils/utils.py (无依赖) + ↓ +core/extractor.py → utils/models.py +core/checker.py → utils/models.py +core/converter.py → utils/models.py, utils/common.py +core/snapshot_parser.py → utils/models.py +formatter.py → utils/models.py, utils/common.py + ↓ +api/link_checker.py → utils/models.py, core/*.py + ↓ +entry/check_issue.py → utils/*.py, core/*.py, formatter.py +``` diff --git a/scripts/python/api/__init__.py b/scripts/python/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/python/core/__init__.py b/scripts/python/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/python/entry/__init__.py b/scripts/python/entry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/python/tests/__init__.py b/scripts/python/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/python/tests/test_results.json b/scripts/python/tests/test_results.json new file mode 100644 index 000000000..4fa81f92e --- /dev/null +++ b/scripts/python/tests/test_results.json @@ -0,0 +1,277 @@ +{ + "total": 10, + "passed": 10, + "failed": 0, + "results": [ + { + "name": "正常场景:单个GKD链接", + "description": "Issue 包含一个有效的 GKD 分享链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "network_detail": "", + "has_convertible": "true", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "\n## 栗子漫画 `com.hbsclj.uth` 1.1.0\nMEIZU 20 · Android 16 · GKD 1.12.1\n\n**MainActivity** — 快查 ID:1 Text:0 · 深度14 · 可点击1 · 21节点\n[1783704841971](https://i.gkd.li/i/29899905)\n\n
\n详细信息\n\n**栗子漫画 `com.hbsclj.uth`**\n\n| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |\n|----------|------|--------|------|----------------|-----|--------|\n| MainActivity | 21 | 1080×2400 | 竖屏 | 3 | 1.12.1 (92) | 0 |\n\n**设备信息**\n\n| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |\n|------|------|--------|------|-----|---------|\n| meizu20 | MEIZU 20 | meizu | meizu | 36 | 16 |\n\n
" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + }, + "mismatches": [] + }, + { + "name": "正常场景:多个GKD链接", + "description": "Issue 包含多个有效的 GKD 分享链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "network_detail": "", + "has_convertible": "true", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "\n## 栗子漫画 `com.hbsclj.uth` 1.1.0\nMEIZU 20 · Android 16 · GKD 1.12.1\n\n**MainActivity** — 快查 ID:1 Text:0 · 深度14 · 可点击1 · 21节点\n[1783704841971](https://i.gkd.li/i/29899905)\n\n**GKD 链接**\n[1783704920484](https://i.gkd.li/i/29899896)\n\n
\n详细信息\n\n**栗子漫画 `com.hbsclj.uth`**\n\n| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |\n|----------|------|--------|------|----------------|-----|--------|\n| MainActivity | 21 | 1080×2400 | 竖屏 | 3 | 1.12.1 (92) | 0 |\n\n**设备信息**\n\n| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |\n|------|------|--------|------|-----|---------|\n| meizu20 | MEIZU 20 | meizu | meizu | 36 | 16 |\n\n
" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + }, + "mismatches": [] + }, + { + "name": "缺失快照:没有链接", + "description": "Issue 没有提供任何快照链接", + "passed": true, + "output": { + "has_snapshot": "false", + "has_unreachable": "false", + "network_status": "skipped", + "network_detail": "", + "has_convertible": "false", + "warning_type": "missing", + "comment_missing": "\n您好 @testuser,由于您没有提供快照链接,此 Issue 已被自动关闭。\n\n请提供正确的快照链接后重新打开或提交新的 Issue。", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "" + }, + "expected": { + "has_snapshot": "false", + "has_unreachable": "false", + "network_status": "skipped", + "has_convertible": "false", + "warning_type": "missing" + }, + "mismatches": [] + }, + { + "name": "不可访问快照:包含snapshot链接", + "description": "Issue 包含不可访问的快照链接(i.gkd.li/snapshot/)", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "true", + "network_status": "skipped", + "network_detail": "", + "has_convertible": "false", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "\n您好 @testuser,检测到您提供了他人无法访问的快照链接(i.gkd.li/snapshot/),请点击查看 [正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。可在下方评论区补充。", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "true", + "network_status": "skipped", + "has_convertible": "false", + "warning_type": "" + }, + "mismatches": [] + }, + { + "name": "链接404:无效链接", + "description": "Issue 包含返回 404 的链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "404", + "network_detail": "", + "has_convertible": "false", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "\n您好 @testuser,检测到您提供的快照链接无法访问:\n\n`https://i.gkd.li/i/99999999`\n\n请确认链接正确后在评论区补充有效的快照链接。", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "404", + "has_convertible": "false", + "warning_type": "" + }, + "mismatches": [] + }, + { + "name": "混合场景:有效+不可访问", + "description": "Issue 同时包含有效链接和不可访问快照链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "true", + "network_status": "ok", + "network_detail": "", + "has_convertible": "true", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "\n您好 @testuser,检测到您提供了他人无法访问的快照链接(i.gkd.li/snapshot/),请点击查看 [正确的分享快照方式说明](https://gkd.li/guide/snapshot#share-note) 。可在下方评论区补充。", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "\n## 栗子漫画 `com.hbsclj.uth` 1.1.0\nMEIZU 20 · Android 16 · GKD 1.12.1\n\n**MainActivity** — 快查 ID:1 Text:0 · 深度14 · 可点击1 · 21节点\n[1783704841971](https://i.gkd.li/i/29899905)\n\n
\n详细信息\n\n**栗子漫画 `com.hbsclj.uth`**\n\n| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |\n|----------|------|--------|------|----------------|-----|--------|\n| MainActivity | 21 | 1080×2400 | 竖屏 | 3 | 1.12.1 (92) | 0 |\n\n**设备信息**\n\n| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |\n|------|------|--------|------|-----|---------|\n| meizu20 | MEIZU 20 | meizu | meizu | 36 | 16 |\n\n
" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "true", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + }, + "mismatches": [] + }, + { + "name": "评论补充链接", + "description": "用户在评论中补充快照链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "network_detail": "", + "has_convertible": "true", + "warning_type": "recovery", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "\n✅ 您好 @testuser,快照链接检查已通过,之前的标记已移除。", + "comment_bot": "\n## 栗子漫画 `com.hbsclj.uth` 1.1.0\nMEIZU 20 · Android 16 · GKD 1.12.1\n\n**MainActivity** — 快查 ID:1 Text:0 · 深度14 · 可点击1 · 21节点\n[1783704841971](https://i.gkd.li/i/29899905)\n\n
\n详细信息\n\n**栗子漫画 `com.hbsclj.uth`**\n\n| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |\n|----------|------|--------|------|----------------|-----|--------|\n| MainActivity | 21 | 1080×2400 | 竖屏 | 3 | 1.12.1 (92) | 0 |\n\n**设备信息**\n\n| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |\n|------|------|--------|------|-----|---------|\n| meizu20 | MEIZU 20 | meizu | meizu | 36 | 16 |\n\n
" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "recovery" + }, + "mismatches": [] + }, + { + "name": "编辑后恢复", + "description": "用户编辑 Issue 后补充了有效链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "network_detail": "", + "has_convertible": "true", + "warning_type": "recovery", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "\n✅ 您好 @testuser,快照链接检查已通过,之前的标记已移除。", + "comment_bot": "\n## 栗子漫画 `com.hbsclj.uth` 1.1.0\nMEIZU 20 · Android 16 · GKD 1.12.1\n\n**MainActivity** — 快查 ID:1 Text:0 · 深度14 · 可点击1 · 21节点\n[1783704841971](https://i.gkd.li/i/29899905)\n\n
\n详细信息\n\n**栗子漫画 `com.hbsclj.uth`**\n\n| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |\n|----------|------|--------|------|----------------|-----|--------|\n| MainActivity | 21 | 1080×2400 | 竖屏 | 3 | 1.12.1 (92) | 0 |\n\n**设备信息**\n\n| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |\n|------|------|--------|------|-----|---------|\n| meizu20 | MEIZU 20 | meizu | meizu | 36 | 16 |\n\n
" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "recovery" + }, + "mismatches": [] + }, + { + "name": "GitHub附件链接", + "description": "Issue 包含 GitHub 附件链接(虚构链接,预期返回404)", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "404", + "network_detail": "", + "has_convertible": "false", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "\n您好 @testuser,检测到您提供的快照链接无法访问:\n\n`https://github.com/user-attachments/files/12345/snapshot.zip`\n\n请确认链接正确后在评论区补充有效的快照链接。", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "404", + "has_convertible": "false", + "warning_type": "" + }, + "mismatches": [] + }, + { + "name": "Markdown格式链接", + "description": "Issue 使用 Markdown 格式提供链接", + "passed": true, + "output": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "network_detail": "", + "has_convertible": "true", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "\n## 栗子漫画 `com.hbsclj.uth` 1.1.0\nMEIZU 20 · Android 16 · GKD 1.12.1\n\n**MainActivity** — 快查 ID:1 Text:0 · 深度14 · 可点击1 · 21节点\n[1783704841971](https://i.gkd.li/i/29899905)\n\n**GKD 链接**\n[1783704920484](https://i.gkd.li/i/29899896)\n\n
\n详细信息\n\n**栗子漫画 `com.hbsclj.uth`**\n\n| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |\n|----------|------|--------|------|----------------|-----|--------|\n| MainActivity | 21 | 1080×2400 | 竖屏 | 3 | 1.12.1 (92) | 0 |\n\n**设备信息**\n\n| 代号 | 型号 | 制造商 | 品牌 | SDK | Android |\n|------|------|--------|------|-----|---------|\n| meizu20 | MEIZU 20 | meizu | meizu | 36 | 16 |\n\n
" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + }, + "mismatches": [] + } + ] +} diff --git a/scripts/python/tests/test_scenarios.json b/scripts/python/tests/test_scenarios.json new file mode 100644 index 000000000..bfacfbc68 --- /dev/null +++ b/scripts/python/tests/test_scenarios.json @@ -0,0 +1,174 @@ +{ + "scenarios": [ + { + "name": "正常场景:单个GKD链接", + "description": "Issue 包含一个有效的 GKD 分享链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n快照:https://i.gkd.li/i/29899905", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + } + }, + { + "name": "正常场景:多个GKD链接", + "description": "Issue 包含多个有效的 GKD 分享链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n开屏广告:https://i.gkd.li/i/29899905\n弹窗广告:https://i.gkd.li/i/29899896", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + } + }, + { + "name": "缺失快照:没有链接", + "description": "Issue 没有提供任何快照链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n这个应用有很多广告需要处理", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "false", + "has_unreachable": "false", + "network_status": "skipped", + "has_convertible": "false", + "warning_type": "missing" + } + }, + { + "name": "不可访问快照:包含snapshot链接", + "description": "Issue 包含不可访问的快照链接(i.gkd.li/snapshot/)", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n快照:https://i.gkd.li/snapshot/abc123", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "true", + "network_status": "skipped", + "has_convertible": "false", + "warning_type": "" + } + }, + { + "name": "链接404:无效链接", + "description": "Issue 包含返回 404 的链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n快照:https://i.gkd.li/i/99999999", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "404", + "has_convertible": "false", + "warning_type": "" + } + }, + { + "name": "混合场景:有效+不可访问", + "description": "Issue 同时包含有效链接和不可访问快照链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n有效快照:https://i.gkd.li/i/29899905\n不可访问快照:https://i.gkd.li/snapshot/abc123", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "true", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + } + }, + { + "name": "评论补充链接", + "description": "用户在评论中补充快照链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告", + "ISSUE_COMMENT_BODY": "补充快照:https://i.gkd.li/i/29899905", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "comment" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "recovery" + } + }, + { + "name": "编辑后恢复", + "description": "用户编辑 Issue 后补充了有效链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n快照:https://i.gkd.li/i/29899905", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "edited" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "recovery" + } + }, + { + "name": "GitHub附件链接", + "description": "Issue 包含 GitHub 附件链接(虚构链接,预期返回404)", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n快照:https://github.com/user-attachments/files/12345/snapshot.zip", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "404", + "has_convertible": "false", + "warning_type": "" + } + }, + { + "name": "Markdown格式链接", + "description": "Issue 使用 Markdown 格式提供链接", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n[开屏广告快照](https://i.gkd.li/i/29899905)\n[弹窗广告快照](https://i.gkd.li/i/29899896)", + "ISSUE_COMMENT_BODY": "", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "opened" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "" + } + } + ] +} diff --git a/scripts/python/tests/verify.py b/scripts/python/tests/verify.py new file mode 100644 index 000000000..78e282405 --- /dev/null +++ b/scripts/python/tests/verify.py @@ -0,0 +1,297 @@ +""" +本地验证脚本 + +用于在修改 Python 脚本后,模拟真实 issue 场景进行验证。 +确保脚本在遇到生产情况时能按预期工作。 + +使用方法: + cd scripts/python + python verify.py + +验证流程: + 1. 加载 test_scenarios.json 中的测试场景 + 2. 对每个场景设置环境变量并运行 check_issue.py + 3. 解析输出结果,与预期结果对比 + 4. 输出验证报告 +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +# 设置标准输出编码为 UTF-8(Windows 兼容) +if sys.platform == "win32": + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +# ── 配置 ── + +SCRIPT_DIR = Path(__file__).parent.parent # 指向 scripts/python 目录 +SCENARIOS_FILE = SCRIPT_DIR / "tests" / "test_scenarios.json" +CHECK_ISSUE_SCRIPT = SCRIPT_DIR / "entry" / "check_issue.py" + + +# ── 测试结果 ── + + +class TestResult: + """单个测试结果""" + + def __init__(self, name: str, description: str): + self.name = name + self.description = description + self.passed = False + self.output = {} + self.expected = {} + self.mismatches = [] + + def to_dict(self) -> dict: + return { + "name": self.name, + "description": self.description, + "passed": self.passed, + "output": self.output, + "expected": self.expected, + "mismatches": self.mismatches, + } + + +# ── 核心函数 ── + + +def load_scenarios() -> list[dict]: + """ + 加载测试场景配置 + + 返回: + 场景列表 + """ + with open(SCENARIOS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("scenarios", []) + + +def run_check_issue(env_vars: dict) -> dict[str, str]: + """ + 运行 check_issue.py 并捕获输出 + + 参数: + env_vars: 环境变量字典 + + 返回: + GITHUB_OUTPUT 解析后的键值对 + """ + # 准备环境变量 + env = os.environ.copy() + env.update(env_vars) + + # 创建临时 GITHUB_OUTPUT 文件 + import tempfile + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + output_file = f.name + + env["GITHUB_OUTPUT"] = output_file + + try: + # 运行脚本 + result = subprocess.run( + [sys.executable, str(CHECK_ISSUE_SCRIPT)], + env=env, + capture_output=True, + text=True, + cwd=str(SCRIPT_DIR), + ) + + if result.returncode != 0: + print(f" ❌ 脚本执行失败: {result.stderr}") + return {} + + # 解析 GITHUB_OUTPUT + outputs = {} + with open(output_file, "r", encoding="utf-8") as f: + content = f.read() + + # 解析 heredoc 格式:key< TestResult: + """ + 验证单个测试场景 + + 参数: + scenario: 场景配置 + + 返回: + TestResult 验证结果 + """ + name = scenario["name"] + description = scenario.get("description", "") + result = TestResult(name, description) + + print(f"\n{'='*60}") + print(f"测试场景: {name}") + print(f"描述: {description}") + print(f"{'='*60}") + + # 运行脚本 + print(" 运行 check_issue.py ...") + output = run_check_issue(scenario["input"]) + + if not output: + print(" ❌ 无法获取输出") + result.output = {} + result.expected = scenario.get("expected", {}) + result.mismatches = ["脚本执行失败"] + return result + + result.output = output + result.expected = scenario.get("expected", {}) + + # 验证每个预期字段 + mismatches = [] + for key, expected_value in result.expected.items(): + actual_value = output.get(key, "") + if actual_value != expected_value: + mismatches.append(f"{key}: 期望='{expected_value}', 实际='{actual_value}'") + print(f" ❌ {key}: 期望='{expected_value}', 实际='{actual_value}'") + else: + print(f" ✓ {key}: {actual_value}") + + result.mismatches = mismatches + result.passed = len(mismatches) == 0 + + if result.passed: + print(" ✅ 测试通过") + else: + print(f" ❌ 测试失败: {len(mismatches)} 个字段不匹配") + + # 显示完整输出 + print("\n 完整输出:") + for key, value in sorted(output.items()): + if value: + # 截断过长的值 + display_value = value[:100] + "..." if len(value) > 100 else value + print(f" {key}: {display_value}") + + return result + + +def run_all_tests() -> list[TestResult]: + """ + 运行所有测试场景 + + 返回: + 所有测试结果列表 + """ + scenarios = load_scenarios() + results = [] + + print(f"\n{'#'*60}") + print(f"# 本地验证: Python 脚本功能测试") + print(f"# 共 {len(scenarios)} 个测试场景") + print(f"{'#'*60}") + + for i, scenario in enumerate(scenarios, 1): + print(f"\n[{i}/{len(scenarios)}]", end="") + result = validate_scenario(scenario) + results.append(result) + + return results + + +def print_summary(results: list[TestResult]): + """ + 打印验证摘要 + + 参数: + results: 所有测试结果 + """ + print(f"\n{'#'*60}") + print(f"# 验证摘要") + print(f"{'#'*60}") + + passed = sum(1 for r in results if r.passed) + failed = sum(1 for r in results if not r.passed) + + print(f"\n总计: {len(results)} 个场景") + print(f"通过: {passed} ✅") + print(f"失败: {failed} ❌") + + if failed > 0: + print(f"\n失败的场景:") + for r in results: + if not r.passed: + print(f"\n ❌ {r.name}") + print(f" 描述: {r.description}") + for mismatch in r.mismatches: + print(f" - {mismatch}") + + +def save_results(results: list[TestResult], output_file: Path = None): + """ + 保存验证结果到 JSON 文件 + + 参数: + results: 所有测试结果 + output_file: 输出文件路径 + """ + if output_file is None: + output_file = SCRIPT_DIR / "test_results.json" + + data = { + "total": len(results), + "passed": sum(1 for r in results if r.passed), + "failed": sum(1 for r in results if not r.passed), + "results": [r.to_dict() for r in results], + } + + with open(output_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + print(f"\n验证结果已保存到: {output_file}") + + +# ── 主入口 ── + + +def main(): + """主函数""" + results = run_all_tests() + print_summary(results) + save_results(results) + + # 返回退出码:有失败则返回 1 + failed = sum(1 for r in results if not r.passed) + sys.exit(1 if failed > 0 else 0) + + +if __name__ == "__main__": + main() From 51ab5ea074ddb2654b13650afb94170fc29fc7e9 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 04:20:17 +0800 Subject: [PATCH 37/90] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=89=8D=E7=9A=84=E6=97=A7=E6=A8=A1=E5=9D=97=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/check_issue.py | 295 ------------------------------ scripts/python/checker.py | 184 ------------------- scripts/python/converter.py | 92 ---------- scripts/python/extractor.py | 105 ----------- scripts/python/snapshot_parser.py | 216 ---------------------- scripts/python/utils.py | 27 --- 6 files changed, 919 deletions(-) delete mode 100644 scripts/python/check_issue.py delete mode 100644 scripts/python/checker.py delete mode 100644 scripts/python/converter.py delete mode 100644 scripts/python/extractor.py delete mode 100644 scripts/python/snapshot_parser.py delete mode 100644 scripts/python/utils.py diff --git a/scripts/python/check_issue.py b/scripts/python/check_issue.py deleted file mode 100644 index 1db314715..000000000 --- a/scripts/python/check_issue.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -Issue 快照链接检查主入口 - -职责:协调各模块执行分析流程,将原子化结果输出到 GITHUB_OUTPUT。 -不直接操作 GitHub API —— 所有 GitHub 操作由 YAML 工作流完成。 - -流程: - 1. 提取链接 → 判断是否缺少快照(唯一致命,关闭 Issue) - 2. 检查不可访问快照链接(i.gkd.li/snapshot/,非致命) - 3. 网络有效性检查(GKD 链接先转 GH 附件 URL 再检查 / GH 附件直接检查) - - 404 非致命,打标签+评论但不关闭 - - 403/5xx 不确定,打标签+评论 - 4. 链接转换 + Bot 评论生成(仅当含有 GitHub 附件链接时) - 5. 编辑/评论恢复判断 - -输出变量(原子化标志,供 YAML 多 Job 条件判断): - - has_snapshot : 是否包含任何快照链接 - - has_unreachable : 是否包含不可访问快照 - - network_status : 网络检查结果 (ok / 404 / uncertain / skipped) - - network_detail : 网络错误详情 - - has_convertible : 是否有可转换的 GitHub 附件 - - warning_type : 警告类型 (missing / unreachable / inaccessible / uncertain / recovery / "") - - comment_missing : 缺失快照评论(含 标记) - - comment_unreachable : 不可访问快照评论(含 标记) - - comment_404 : 链接404评论(含 标记) - - comment_uncertain : 网络不确定评论(含 标记) - - comment_recovery : 恢复评论(含 标记) - - comment_bot : Bot 评论(含 标记) -""" - -import os -from dataclasses import dataclass - -from extractor import extract_links -from checker import ( - check_unreachable_links, - check_network_links, - gkd_to_gh_attachment_url, -) -from converter import convert_github_attachments -from snapshot_parser import download_and_parse, SnapshotInfo -from formatter import ( - build_warning_missing, - build_warning_unreachable, - build_warning_inaccessible, - build_warning_uncertain, - build_recovery_comment, - build_bot_comment, -) -from utils import write_output - - -# ── 快照相关链接类型集合 ── - -_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} - - -# ── 网络检查聚合结果 ── - - -@dataclass -class _NetworkCheckResult: - """网络检查聚合结果,记录首次遇到的致命/不确定错误""" - - status: str = "ok" - detail: str = "" - fail_url: str = "" - uncertain_url: str = "" - uncertain_code: int = 0 - uncertain_detail: str = "" - - -# ── 主流程 ── - - -def main(): - body = os.environ.get("ISSUE_BODY", "") or "" - comment_body = os.environ.get("ISSUE_COMMENT_BODY", "") or "" - issue_user = os.environ.get("ISSUE_USER", "") - issue_action = os.environ.get("ISSUE_ACTION", "") - - # 合并 Issue Body 和评论内容一起分析(评论补充的链接也参与检查) - full_text = body + "\n" + comment_body if comment_body else body - - has_snapshot = "true" - has_unreachable = "false" - network_status = "skipped" - network_detail = "" - has_convertible = "false" - warning_type = "" - comment_missing = "" - comment_unreachable = "" - comment_404 = "" - comment_uncertain = "" - comment_recovery = "" - comment_bot = "" - - # ── 第一步:提取所有链接 ── - links = extract_links(full_text) - - # ── 第二步:判断是否缺少快照(唯一致命 → 提前返回) ── - has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) - - if not has_any_snapshot: - comment_missing = build_warning_missing(issue_user) - _output( - has_snapshot="false", - has_unreachable="false", - network_status="skipped", - network_detail="", - has_convertible="false", - warning_type="missing", - comment_missing=comment_missing, - comment_unreachable="", - comment_404="", - comment_uncertain="", - comment_recovery="", - comment_bot="", - ) - return - - # ── 第三步:检查不可访问快照链接(非致命,继续后续检查) ── - unreachable_links = check_unreachable_links(links) - has_unreachable = "true" if unreachable_links else "false" - if unreachable_links: - comment_unreachable = build_warning_unreachable(issue_user) - - # ── 第四步:网络有效性检查 ── - # GKD 分享链接先转换为 GH 附件 URL 再检查,GH 附件链接直接检查 - net_result = _check_all_links(links) - network_status = net_result.status - network_detail = net_result.detail - - if network_status == "404": - comment_404 = build_warning_inaccessible(issue_user, net_result.fail_url) - - if network_status == "uncertain": - comment_uncertain = build_warning_uncertain( - issue_user, - net_result.uncertain_url, - net_result.uncertain_code, - net_result.uncertain_detail, - ) - - # ── 第五步:链接转换 + 快照解析 + Bot 评论生成 ── - # 仅当含有可下载的快照链接(GH 附件或 GKD 分享链接)时执行 - snapshots, gkd_links = _parse_all_snapshots(links) - if snapshots or gkd_links: - has_convertible = "true" - comment_body = build_bot_comment(snapshots, gkd_links) - comment_bot = "\n" + comment_body - - # ── 第六步:编辑/评论恢复判断 ── - # 当 edited 或 issue_comment 触发且所有检查均通过时,触发恢复流程 - # 恢复条件:edited/comment + 至少有一个有效快照链接 + 网络OK - # 不要求旧问题链接消失——作者补充有效链接即可恢复 - has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in links) - - if issue_action in ("edited", "comment") and has_valid_snapshot and network_status in ("ok", "skipped"): - warning_type = "recovery" - comment_recovery = build_recovery_comment(issue_user) - - _output( - has_snapshot=has_snapshot, - has_unreachable=has_unreachable, - network_status=network_status, - network_detail=network_detail, - has_convertible=has_convertible, - warning_type=warning_type, - comment_missing=comment_missing, - comment_unreachable=comment_unreachable, - comment_404=comment_404, - comment_uncertain=comment_uncertain, - comment_recovery=comment_recovery, - comment_bot=comment_bot, - ) - - -def _check_all_links(links: list) -> _NetworkCheckResult: - """ - 对所有可检查链接执行网络有效性检查。 - - 检查对象: - - GitHub 附件链接:直接检查原始 URL - - GKD 分享链接:先转换为 GH 附件 URL 再检查 - - 遵循 Fail Fast 原则:遇到 404 立即返回。 - 不确定结果(403/5xx)为非致命,记录但不中断。 - - 返回:_NetworkCheckResult 聚合结果 - """ - result = _NetworkCheckResult() - - for lnk in links: - if lnk.kind == "github_attachment": - check_url = lnk.url - elif lnk.kind == "gkd": - check_url = gkd_to_gh_attachment_url(lnk.url) - if not check_url: - continue - else: - continue - - check = check_network_links(check_url) - - if check.status == "404": - result.status = "404" - result.fail_url = lnk.url - return result - - if check.status == "uncertain" and result.status != "uncertain": - result.status = "uncertain" - result.detail = f"HTTP {check.status_code}: {check.detail}" - result.uncertain_url = lnk.url - result.uncertain_code = check.status_code - result.uncertain_detail = check.detail - - return result - - -def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: - """ - 下载并解析所有快照链接,同 Activity 只下载一个代表。 - - 返回: - - snapshots:解析成功的 SnapshotInfo 列表 - - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] - """ - from converter import GKD_PROXY_TEMPLATE - - snapshots: list[SnapshotInfo] = [] - gkd_links: list[tuple[str, str]] = [] - - # 已下载的 Activity 集合,用于去重 - seen_activities: set[str] = set() - - # 先处理 GitHub 附件链接 - for lnk in links: - if lnk.kind != "github_attachment": - continue - - converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - snap = download_and_parse(lnk.url, converted_url) - - if snap is None: - # 下载失败,仍作为可转换链接保留 - gkd_links.append((lnk.display_text or _extract_filename(lnk.url), converted_url)) - continue - - act_key = f"{snap.app_id}|{snap.activity_id}" - if act_key in seen_activities: - # 同 Activity 已有代表,只记录链接 - gkd_links.append((snap.snapshot_id or _extract_filename(lnk.url), converted_url)) - else: - seen_activities.add(act_key) - snapshots.append(snap) - - # 再处理 GKD 分享链接(GKD 链接原样保留,不套代理模板) - for lnk in links: - if lnk.kind != "gkd": - continue - - gh_url = gkd_to_gh_attachment_url(lnk.url) - if not gh_url: - continue - - snap = download_and_parse(gh_url, lnk.url) - - if snap is None: - gkd_links.append((lnk.display_text or lnk.url, lnk.url)) - continue - - act_key = f"{snap.app_id}|{snap.activity_id}" - if act_key in seen_activities: - gkd_links.append((snap.snapshot_id or lnk.url, lnk.url)) - else: - seen_activities.add(act_key) - snapshots.append(snap) - - return snapshots, gkd_links - - -def _extract_filename(url: str) -> str: - """从 URL 中提取文件名""" - return url.rsplit("/", 1)[-1] if "/" in url else url - - -def _output(**kwargs): - """将所有分析结果写入 GITHUB_OUTPUT。""" - for key, value in kwargs.items(): - write_output(key, value) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/python/checker.py b/scripts/python/checker.py deleted file mode 100644 index 06f7951d8..000000000 --- a/scripts/python/checker.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -链接检查模块 - -负责两类检查: -1. 不可访问快照链接检查:识别 i.gkd.li/snapshot/ 链接 -2. 网络有效性检查:对链接发起 HTTP 请求,验证可访问性 - - GitHub 附件链接直接检查 - - GKD 分享链接先转换为 GH 附件 URL 再检查 - -本模块只返回检查结果,不做任何业务判断(如是否关闭 Issue)。 -""" - -import re -from dataclasses import dataclass - -from extractor import LinkInfo - - -# ── 数据结构 ── - - -@dataclass -class NetworkResult: - """网络请求检查结果""" - - status: str # "ok" / "404" / "uncertain" - status_code: int = 0 # HTTP 状态码 - detail: str = "" # 错误详情(供折叠展示) - - -# ── GKD 链接 → GH 附件 URL 转换 ── - -# 从 GKD 分享链接中提取数字 ID -_RE_GKD_ID = re.compile(r"https://i\.gkd\.li/i/(\d+)") - -# GH 附件 URL 模板:{id} 为 GKD 链接中的数字,file.zip 为固定占位符 -_GH_ATTACHMENT_TEMPLATE = "https://github.com/user-attachments/files/{id}/file.zip" - - -def gkd_to_gh_attachment_url(gkd_url: str) -> str | None: - """ - 将 GKD 分享链接转换为 GitHub 附件 URL,用于网络可访问性检查。 - - 例如:https://i.gkd.li/i/29722723 → https://github.com/user-attachments/files/29722723/file.zip - - 返回 None 表示 URL 不符合 GKD 分享链接格式。 - """ - match = _RE_GKD_ID.match(gkd_url) - if not match: - return None - return _GH_ATTACHMENT_TEMPLATE.format(id=match.group(1)) - - -# ── 不可访问快照链接检查 ── - - -def check_unreachable_links(links: list[LinkInfo]) -> list[LinkInfo]: - """ - 筛选出所有 i.gkd.li/snapshot/ 类型的不可访问链接。 - - 此类链接仅作者可访问,他人无法打开。 - """ - return [lnk for lnk in links if lnk.kind == "unreachable_snapshot"] - - -# ── 网络有效性检查 ── - - -def check_network_links(url: str, timeout: int = 20) -> NetworkResult: - """ - 对单个 URL 发起网络请求,验证其可访问性。 - - 请求策略(按优先级): - 1. HEAD 请求 —— 最快,只获取响应头 - 2. GET 请求 + Range 头 —— 只请求前 1 字节,兼容不支持 HEAD 的服务器 - - 返回值: - - status="ok":链接可正常访问 - - status="404":链接返回 404,确认不可访问 - - status="uncertain":返回 403/5xx 等不确定状态码 - """ - import urllib.request - import urllib.error - - result = _try_head_request(url, timeout) - if result is not None: - return result - - return _try_get_range_request(url, timeout) - - -def _try_head_request(url: str, timeout: int) -> NetworkResult | None: - """ - 发起 HEAD 请求。 - - 返回 None 表示服务器不支持 HEAD(如返回 405), - 需要回退到 GET 请求。 - """ - import urllib.request - import urllib.error - - try: - req = urllib.request.Request(url, method="HEAD") - req.add_header("User-Agent", "GKD-Issue-Checker/1.0") - with urllib.request.urlopen(req, timeout=timeout) as resp: - return NetworkResult(status="ok", status_code=resp.status) - except urllib.error.HTTPError as e: - if e.code == 404: - return NetworkResult(status="404", status_code=404) - if e.code == 405: - return None - if e.code == 403: - return NetworkResult( - status="uncertain", - status_code=403, - detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", - ) - if 500 <= e.code < 600: - return NetworkResult( - status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", - ) - return NetworkResult( - status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} {e.reason}", - ) - except Exception as e: - return NetworkResult( - status="uncertain", - status_code=0, - detail=f"请求异常: {type(e).__name__}: {e}", - ) - - -def _try_get_range_request(url: str, timeout: int) -> NetworkResult: - """ - 发起 GET 请求 + Range 头(只请求前 1 字节)。 - - 用于兼容不支持 HEAD 方法的服务器。 - """ - import urllib.request - import urllib.error - - try: - req = urllib.request.Request(url, method="GET") - req.add_header("User-Agent", "GKD-Issue-Checker/1.0") - req.add_header("Range", "bytes=0-0") - with urllib.request.urlopen(req, timeout=timeout) as resp: - code = resp.status - if code in (200, 206): - return NetworkResult(status="ok", status_code=code) - return NetworkResult( - status="uncertain", - status_code=code, - detail=f"GET 请求返回非预期状态码: {code}", - ) - except urllib.error.HTTPError as e: - if e.code == 404: - return NetworkResult(status="404", status_code=404) - if e.code == 403: - return NetworkResult( - status="uncertain", - status_code=403, - detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", - ) - if 500 <= e.code < 600: - return NetworkResult( - status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", - ) - return NetworkResult( - status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} {e.reason}", - ) - except Exception as e: - return NetworkResult( - status="uncertain", - status_code=0, - detail=f"请求异常: {type(e).__name__}: {e}", - ) \ No newline at end of file diff --git a/scripts/python/converter.py b/scripts/python/converter.py deleted file mode 100644 index b14ec9177..000000000 --- a/scripts/python/converter.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -链接转换模块 - -将 GitHub 附件链接转换为 GKD 代理链接。 -仅处理 kind == "github_attachment" 的链接,GKD 链接原样保留。 - -转换公式:https://i.gkd.li/i?url={{原始GitHub附件URL}} - -本模块只负责数据转换,不负责评论格式化(由 formatter.py 处理)。 -""" - -import re -from dataclasses import dataclass - -from extractor import LinkInfo - - -# ── 数据结构 ── - - -@dataclass -class ConvertedLink: - """转换后的链接信息""" - - original_url: str # 原始 GitHub 附件 URL - converted_url: str # 转换后的 GKD 代理 URL - display_text: str # 原始 Markdown 链接的显示文字 - app_name: str # 从文件名提取的 App 名称(不匹配时为空) - activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) - timestamp: str # 从文件名提取的时间戳(不匹配时为空) - - -# ── 常量 ── - -# GKD 代理链接模板 -GKD_PROXY_TEMPLATE = "https://i.gkd.li/i?url={url}" - -# 从 URL 中提取文件名的正则 -_RE_FILENAME = re.compile(r"https://github\.com/user-attachments/files/\d+/(.+)") - -# 文件名模式:{App}_{Activity}-{timestamp}.zip -_RE_NAME_PATTERN = re.compile( - r"^(?P.+?)_(?P.+?)-(?P\d+)\.zip$" -) - - -# ── 转换函数 ── - - -def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: - """ - 将 GitHub 附件链接转换为 GKD 代理链接。 - - 仅处理 kind == "github_attachment" 的链接。 - 文件名不符合 {App}_{Activity}-{timestamp}.zip 模式的, - app_name / activity_name / timestamp 设为空字符串。 - """ - results: list[ConvertedLink] = [] - for lnk in links: - if lnk.kind != "github_attachment": - continue - - # 执行 URL 转换 - converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - - # 从 URL 中提取文件名 - filename_match = _RE_FILENAME.match(lnk.url) - filename = filename_match.group(1) if filename_match else "" - - # 尝试解析文件名中的 App / Activity / timestamp - app_name = "" - activity_name = "" - timestamp = "" - if filename: - name_match = _RE_NAME_PATTERN.match(filename) - if name_match: - app_name = name_match.group("app") - activity_name = name_match.group("activity") - timestamp = name_match.group("timestamp") - - results.append( - ConvertedLink( - original_url=lnk.url, - converted_url=converted_url, - display_text=lnk.display_text, - app_name=app_name, - activity_name=activity_name, - timestamp=timestamp, - ) - ) - - return results \ No newline at end of file diff --git a/scripts/python/extractor.py b/scripts/python/extractor.py deleted file mode 100644 index 9af07b644..000000000 --- a/scripts/python/extractor.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -链接提取与分类模块 - -从 Issue Body 中提取所有快照相关链接,并分类为: -- gkd:GKD 分享链接 (https://i.gkd.li/i/XXXXXXXX) -- github_attachment:GitHub 附件链接 (github.com/user-attachments/files/) -- unreachable_snapshot:不可访问的快照链接 (i.gkd.li/snapshot/) - -本模块只负责提取和分类,不做任何检查或判断。 -""" - -import re -from dataclasses import dataclass - - -# ── 数据结构 ── - - -@dataclass -class LinkInfo: - """提取出的单条链接信息""" - - url: str # 完整 URL - kind: str # 分类:gkd / github_attachment / unreachable_snapshot - display_text: str # Markdown 链接的显示文字,纯文本时为空 - - -# ── 正则模式 ── - -# Markdown 格式链接:[显示文字](URL) -_RE_MD_LINK = re.compile(r"\[([^\]]*)\]\(([^)]+)\)") - -# GKD 分享链接:https://i.gkd.li/i/数字 -_RE_GKD_LINK = re.compile(r"https://i\.gkd\.li/i/\d+") - -# GitHub 附件链接:https://github.com/user-attachments/files/... -_RE_GITHUB_ATTACHMENT = re.compile( - r"https://github\.com/user-attachments/files/[^\s\)]+" -) - -# 不可访问的快照链接:https://i.gkd.li/snapshot/... -_RE_UNREACHABLE_SNAPSHOT = re.compile(r"https://i\.gkd\.li/snapshot/[^\s\)]*") - - -# ── 分类函数 ── - - -def _classify_url(url: str) -> str | None: - """ - 对单个 URL 进行分类。 - - 返回值: - - "gkd":GKD 分享链接 - - "github_attachment":GitHub 附件链接 - - "unreachable_snapshot":不可访问的快照链接 - - None:不属于以上任何类别(忽略) - """ - if _RE_UNREACHABLE_SNAPSHOT.match(url): - return "unreachable_snapshot" - if _RE_GKD_LINK.match(url): - return "gkd" - if _RE_GITHUB_ATTACHMENT.match(url): - return "github_attachment" - return None - - -# ── 主提取函数 ── - - -def extract_links(body: str) -> list[LinkInfo]: - """ - 从 Issue Body 中提取所有快照相关链接。 - - 处理两种格式: - 1. Markdown 链接:[文字](URL) → 保留显示文字 - 2. 纯文本 URL:直接匹配 → display_text 为空 - - 去重策略:同一 URL 只保留首次出现。 - """ - seen: set[str] = set() - results: list[LinkInfo] = [] - - # 先提取 Markdown 格式链接(优先保留显示文字) - for match in _RE_MD_LINK.finditer(body): - display_text = match.group(1) - url = match.group(2) - kind = _classify_url(url) - if kind and url not in seen: - seen.add(url) - results.append(LinkInfo(url=url, kind=kind, display_text=display_text)) - - # 再提取纯文本 URL(排除已被 Markdown 链接捕获的) - all_url_patterns = [ - (_RE_UNREACHABLE_SNAPSHOT, "unreachable_snapshot"), - (_RE_GKD_LINK, "gkd"), - (_RE_GITHUB_ATTACHMENT, "github_attachment"), - ] - for pattern, kind in all_url_patterns: - for match in pattern.finditer(body): - url = match.group(0) - if url not in seen: - seen.add(url) - results.append(LinkInfo(url=url, kind=kind, display_text="")) - - return results \ No newline at end of file diff --git a/scripts/python/snapshot_parser.py b/scripts/python/snapshot_parser.py deleted file mode 100644 index 3d8cc73e5..000000000 --- a/scripts/python/snapshot_parser.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -快照解析模块 - -负责下载 zip 压缩包、提取 snapshot.json、解析为结构化数据。 -本模块只负责数据解析,不负责评论格式化(由 formatter.py 处理)。 - -解析策略: -- 下载 zip 到内存,不解压到磁盘 -- 从 zip 中查找 snapshot.json(兼容不同目录层级) -- 兼容精简模式(顶层字段)和完整模式(appInfo/gkdAppInfo 对象) -- 缺失字段使用合理默认值 -""" - -import io -import json -import zipfile -from dataclasses import dataclass - -import urllib.request -import urllib.error - - -# ── 数据结构 ── - - -@dataclass -class SnapshotInfo: - """快照解析后的结构化信息""" - - # 应用信息 - app_name: str - app_id: str - app_version_name: str - app_version_code: str - - # 界面信息 - activity_id: str - snapshot_id: str - - # 屏幕信息 - screen_width: int - screen_height: int - is_landscape: bool - - # GKD 信息 - gkd_version_name: str - gkd_version_code: str - gkd_user_id: str - - # 设备信息 - device_code: str - device_model: str - device_manufacturer: str - device_brand: str - device_sdk: int - device_release: str - - # 节点统计 - total_nodes: int - visible_nodes: int - clickable_nodes: int - max_depth: int - id_qf_count: int - text_qf_count: int - - # 链接 - original_url: str - converted_url: str - - -# ── 下载与解析 ── - - -def download_and_parse(url: str, converted_url: str = "", timeout: int = 30) -> SnapshotInfo | None: - """ - 下载 zip 并解析快照信息。 - - 参数: - - url:zip 文件的下载地址 - - converted_url:转换后的 GKD 代理链接(用于 Bot 评论展示) - - timeout:下载超时时间(秒) - - 返回 SnapshotInfo,下载或解析失败时返回 None。 - """ - zip_data = _download_zip(url, timeout) - if not zip_data: - return None - - snapshot_json = _extract_snapshot_json(zip_data) - if not snapshot_json: - return None - - return _parse_snapshot(snapshot_json, url, converted_url) - - -# ── 内部函数 ── - - -def _download_zip(url: str, timeout: int) -> bytes | None: - """ - 下载 zip 文件到内存。 - - 返回 zip 的字节数据,失败时返回 None。 - """ - try: - req = urllib.request.Request(url, method="GET") - req.add_header("User-Agent", "GKD-Issue-Checker/1.0") - with urllib.request.urlopen(req, timeout=timeout) as resp: - return resp.read() - except Exception: - return None - - -def _extract_snapshot_json(zip_data: bytes) -> dict | None: - """ - 从 zip 字节数据中提取 snapshot.json 的内容。 - - 查找 zip 内所有 .json 文件,优先选择名为 snapshot.json 的。 - 兼容不同目录层级(根目录或子目录)。 - """ - try: - with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: - # 优先查找 snapshot.json - for name in zf.namelist(): - if name.endswith("snapshot.json"): - with zf.open(name) as f: - return json.loads(f.read().decode("utf-8")) - - # 回退:查找任意 .json 文件 - for name in zf.namelist(): - if name.endswith(".json"): - with zf.open(name) as f: - return json.loads(f.read().decode("utf-8")) - except Exception: - pass - - return None - - -def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> SnapshotInfo: - """ - 将 snapshot.json 解析为 SnapshotInfo。 - - 兼容精简模式(顶层 appName 等字段)和完整模式(appInfo 对象)。 - 缺失字段使用合理默认值。 - """ - # 应用信息:优先完整模式 appInfo,回退精简模式顶层字段 - app_info = data.get("appInfo", {}) or {} - app_name = app_info.get("name") or data.get("appName", "") - app_version_name = str(app_info.get("versionName") or data.get("appVersionName", "")) - app_version_code = str(app_info.get("versionCode") or data.get("appVersionCode", "")) - - # GKD 信息:优先 gkdAppInfo,回退顶层字段 - gkd_info = data.get("gkdAppInfo", {}) or {} - gkd_version_name = str(gkd_info.get("versionName") or data.get("gkdVersionName", "")) - gkd_version_code = str(gkd_info.get("versionCode") or data.get("gkdVersionCode", "")) - gkd_user_id = str(gkd_info.get("userId", "")) - - # 设备信息 - device = data.get("device", {}) or {} - - # 节点统计 - nodes = data.get("nodes", []) or [] - total_nodes = len(nodes) - visible_nodes = 0 - clickable_nodes = 0 - max_depth = 0 - id_qf_count = 0 - text_qf_count = 0 - - for node in nodes: - attr = node.get("attr", {}) or {} - - if attr.get("visibleToUser", False): - visible_nodes += 1 - if attr.get("clickable", False): - clickable_nodes += 1 - - depth = attr.get("depth", 0) - if depth > max_depth: - max_depth = depth - - # idQf / textQf 缺失视为 null,仅 true 时计数 - if node.get("idQf") is True: - id_qf_count += 1 - if node.get("textQf") is True: - text_qf_count += 1 - - return SnapshotInfo( - app_name=app_name, - app_id=data.get("appId", ""), - app_version_name=app_version_name, - app_version_code=app_version_code, - activity_id=data.get("activityId", ""), - snapshot_id=str(data.get("id", "")), - screen_width=data.get("screenWidth", 0), - screen_height=data.get("screenHeight", 0), - is_landscape=data.get("isLandscape", False), - gkd_version_name=gkd_version_name, - gkd_version_code=gkd_version_code, - gkd_user_id=gkd_user_id, - device_code=device.get("device", ""), - device_model=device.get("model", ""), - device_manufacturer=device.get("manufacturer", ""), - device_brand=device.get("brand", ""), - device_sdk=device.get("sdkInt", 0), - device_release=device.get("release", ""), - total_nodes=total_nodes, - visible_nodes=visible_nodes, - clickable_nodes=clickable_nodes, - max_depth=max_depth, - id_qf_count=id_qf_count, - text_qf_count=text_qf_count, - original_url=original_url, - converted_url=converted_url, - ) \ No newline at end of file diff --git a/scripts/python/utils.py b/scripts/python/utils.py deleted file mode 100644 index 93e116c1e..000000000 --- a/scripts/python/utils.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -公共工具模块 - -提供 GITHUB_OUTPUT 写入等共享工具函数,供其他模块调用。 -本模块不包含任何业务逻辑。 -""" - -import os - - -# ── 本工作流管理的所有标签 ── - -MANAGED_LABELS = [ - "缺失快照(missing-snapshot)", - "需补充链接(need-supplement-link)", - "链接无法访问(inaccessible-link)", -] - - -def write_output(key: str, value: str): - """ - 向 GITHUB_OUTPUT 写入一个键值对。 - - 使用 heredoc 语法支持多行值,确保 Markdown 内容正确传递。 - """ - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: - f.write(f"{key}< Date: Sat, 11 Jul 2026 04:33:06 +0800 Subject: [PATCH 38/90] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=90=8E=E7=9A=84=E6=A8=A1=E5=9D=97=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/entry/check_issue.py | 2 +- scripts/python/tests/verify.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index b8930a193..f292c7f11 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -235,7 +235,7 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st - snapshots:解析成功的 SnapshotInfo 列表 - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] """ - from converter import GKD_PROXY_TEMPLATE + from core.converter import GKD_PROXY_TEMPLATE snapshots: list[SnapshotInfo] = [] gkd_links: list[tuple[str, str]] = [] diff --git a/scripts/python/tests/verify.py b/scripts/python/tests/verify.py index 78e282405..8a9301e09 100644 --- a/scripts/python/tests/verify.py +++ b/scripts/python/tests/verify.py @@ -96,6 +96,13 @@ def run_check_issue(env_vars: dict) -> dict[str, str]: env["GITHUB_OUTPUT"] = output_file + # 设置 PYTHONPATH 为 scripts/python 目录,确保模块导入正常 + python_path = str(SCRIPT_DIR) + if "PYTHONPATH" in env: + env["PYTHONPATH"] = python_path + os.pathsep + env["PYTHONPATH"] + else: + env["PYTHONPATH"] = python_path + try: # 运行脚本 result = subprocess.run( From e7331673c76796982f6029cafa71a2f7eff4e180 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 07:38:39 +0800 Subject: [PATCH 39/90] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=20CI=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E4=B8=AD=E7=9A=84=20Python=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 2cebc2304..3bd69b591 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -106,7 +106,7 @@ jobs: comment_bot= EOF else - python3 scripts/python/check_issue.py + python3 scripts/python/entry/check_issue.py fi # ── 线性步骤一:缺失快照 → 打标签 + 评论 + 关闭(致命,阻断后续所有 Job) ── From 0a81012c0cb0f194ccb5f2b1e9f2011289f0f7d9 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 07:39:42 +0800 Subject: [PATCH 40/90] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20CLAUDE.md=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=20Python=20=E8=84=9A=E6=9C=AC=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs: 更新 project_rules.md 中的 Python 模块结构 --- .trae/rules/project_rules.md | 25 ++++++++++++++++++------- CLAUDE.md | 14 +++++++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md index 60301a408..d9841ead3 100644 --- a/.trae/rules/project_rules.md +++ b/.trae/rules/project_rules.md @@ -277,13 +277,24 @@ device_model · Android release · GKD version ``` scripts/python/ - ├── check_issue.py # 主入口:协调各模块,输出分析结果 - ├── extractor.py # 链接提取与分类 - ├── checker.py # 两类检查(不可访问快照/网络)+ GKD→GH 转换 - ├── converter.py # GitHub 附件 → GKD 代理链接转换 - ├── snapshot_parser.py # 快照 zip 下载+解析,输出 SnapshotInfo - ├── formatter.py # Bot 评论 Markdown 格式化生成 - └── utils.py # 公共工具函数(GITHUB_OUTPUT 写入等) + ├── core/ # 核心功能层 + │ ├── extractor.py # 链接提取与分类 + │ ├── checker.py # 网络检查 + │ ├── converter.py # 链接转换 + │ └── snapshot_parser.py # 快照解析 + ├── utils/ # 工具模块层 + │ ├── models.py # 数据结构定义 + │ ├── common.py # 通用工具函数 + │ └── utils.py # GITHUB_OUTPUT 工具 + ├── api/ # 高层 API 层 + │ └── link_checker.py # 可复用的链接检查器 + ├── entry/ # 入口脚本层 + │ └── check_issue.py # Issue 场景主入口 + ├── tests/ # 测试层 + │ ├── verify.py # 本地验证脚本 + │ └── test_scenarios.json # 测试场景配置 + ├── formatter.py # 评论格式化(跨层使用) + └── README.md # 模块说明文档 ``` ### 模块化要求 diff --git a/CLAUDE.md b/CLAUDE.md index c3a3a677b..1c4ccabd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,11 +187,15 @@ scripts/python/ ## Python 脚本 -`scripts/python/` 包含 GitHub Issue 自动化工具: -- `check_issue.py` — 分析 Issue 内容中的快照链接(缺失、不可访问、可转换) -- `snapshot_parser.py` — 解析快照节点树 -- `formatter.py` — 从快照数据格式化规则模板 -- `converter.py` — 将快照转换为 GKD 规则格式 +`scripts/python/` 包含 GitHub Issue 自动化工具,按职责分层组织: + +- `entry/check_issue.py` — Issue 场景主入口,分析快照链接并输出结果 +- `core/snapshot_parser.py` — 下载并解析快照 zip 文件 +- `formatter.py` — 生成 Bot 评论的 Markdown 内容 +- `core/converter.py` — GitHub 附件 → GKD 代理链接转换 +- `api/link_checker.py` — 可复用的链接检查 API(可在其他 CI 中使用) + +详细说明见 `scripts/python/README.md` ## 构建输出 From 216de4916b006180129eccab5d3070e4d22bac7a Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 07:40:09 +0800 Subject: [PATCH 41/90] =?UTF-8?q?chore:=20=E5=B0=86=20test=5Fresults.json?= =?UTF-8?q?=20=E6=B7=BB=E5=8A=A0=E5=88=B0=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2a4403a66..d57c850e2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,4 @@ node_modules package-lock.json yarn.lock -__pycache__/ \ No newline at end of file +__pycache__/scripts/python/test_results.json From b535575f5fe62446a434f1f6a09ecc4876e09ff5 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 07:40:51 +0800 Subject: [PATCH 42/90] =?UTF-8?q?fix:=20CI=20=E4=B8=AD=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=20PYTHONPATH=20=E7=A1=AE=E4=BF=9D=E6=A8=A1=E5=9D=97=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=AD=A3=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/entry/check_issue.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index f292c7f11..f05c7e4e7 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -29,8 +29,15 @@ """ import os +import sys +from pathlib import Path from dataclasses import dataclass +# 自动设置模块搜索路径,确保能在任意目录下执行 +_script_dir = Path(__file__).parent.parent # 指向 scripts/python 目录 +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + from utils.models import LinkInfo, SnapshotInfo from utils.common import extract_filename from core.extractor import extract_links From 296fdc556204e532c7156151fa65c384b95ee114 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 07:48:08 +0800 Subject: [PATCH 43/90] =?UTF-8?q?chore:=20=E6=8E=92=E9=99=A4.pyc=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d57c850e2..234b60dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ package-lock.json yarn.lock __pycache__/scripts/python/test_results.json +*.pyc From c16338f1c720f1858d3be4b84638f25533d0640a Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 08:04:54 +0800 Subject: [PATCH 44/90] =?UTF-8?q?fix:=20=E8=AF=84=E8=AE=BA=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=8F=AA=E5=88=86=E6=9E=90=E8=AF=84=E8=AE=BA=E5=86=85?= =?UTF-8?q?=E5=AE=B9=EF=BC=8C=E4=BF=AE=E5=A4=8D=20recovery=20=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E8=A7=A6=E5=8F=91=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/entry/check_issue.py | 8 ++++++-- scripts/python/tests/test_scenarios.json | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index f05c7e4e7..fdbd057cb 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -88,8 +88,12 @@ def main(): issue_user = os.environ.get("ISSUE_USER", "") issue_action = os.environ.get("ISSUE_ACTION", "") - # 合并 Issue Body 和评论内容一起分析(评论补充的链接也参与检查) - full_text = body + "\n" + comment_body if comment_body else body + # 当评论事件时,只分析评论内容(以最新评论为主) + # 当 opened/edited 事件时,分析 Issue Body + if issue_action == "comment" and comment_body: + full_text = comment_body + else: + full_text = body has_snapshot = "true" has_unreachable = "false" diff --git a/scripts/python/tests/test_scenarios.json b/scripts/python/tests/test_scenarios.json index bfacfbc68..33ec88770 100644 --- a/scripts/python/tests/test_scenarios.json +++ b/scripts/python/tests/test_scenarios.json @@ -169,6 +169,23 @@ "has_convertible": "true", "warning_type": "" } + }, + { + "name": "Issue失效链接+评论补充有效链接", + "description": "Issue 原始链接失效(404),用户在评论中补充有效 GKD 链接,应触发 recovery", + "input": { + "ISSUE_BODY": "## 适配请求\n\n请适配这个应用的广告\n\n快照:https://github.com/user-attachments/files/12345/snapshot.zip", + "ISSUE_COMMENT_BODY": "补充有效快照:https://i.gkd.li/i/29899905", + "ISSUE_USER": "testuser", + "ISSUE_ACTION": "comment" + }, + "expected": { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "ok", + "has_convertible": "true", + "warning_type": "recovery" + } } ] } From dbbb31de3634ab5f7cf647931ad8be97f74aebf7 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Sat, 11 Jul 2026 08:29:21 +0800 Subject: [PATCH 45/90] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交前检查 --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1c4ccabd8..46d1dc16a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,9 @@ pnpm run format # Prettier 格式化所有源文件 - 简洁明了,一句话概括改动内容 - 示例:`修复 Python 脚本编码问题`、`新增链接检查验证工具`、`优化模块依赖结构` +### 提交前检查 +- 在提交前必须保证已在本地完成此次commit所有测试!禁止使用简易测试替代此次提交的所有可能发生的事件略过,必须考虑周到且测试通过后方可commit + ## 架构设计 ### 核心流程 From 70c38e3d58b2af339c8fb1d5539084e77b2ad9c0 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 02:45:04 +0800 Subject: [PATCH 46/90] =?UTF-8?q?feat(rules):=20claude=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/architecture.md | 72 ++++++++ .claude/rules/ci-cd.md | 337 ++++++++++++++++++++++++++++++++++ .claude/rules/conventions.md | 75 ++++++++ .claude/settings.json | 17 ++ CLAUDE.md | 221 ++++------------------ 5 files changed, 532 insertions(+), 190 deletions(-) create mode 100644 .claude/rules/architecture.md create mode 100644 .claude/rules/ci-cd.md create mode 100644 .claude/rules/conventions.md create mode 100644 .claude/settings.json diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 000000000..f5096b00d --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,72 @@ +# 项目架构 + +## 项目概述 + +GKD 订阅规则仓库 — 为 [GKD](https://gkd.li/) 提供第三方订阅规则。GKD 是一款基于 Android 无障碍服务的工具,可自动关闭广告、弹窗和不需要的 UI 元素。规则以 TypeScript 文件编写,定义 UI 节点选择器来匹配 Android 视图层级快照。 + +## 核心流程 + +``` +src/apps/*.ts ──┐ +src/globalGroups.ts ──┤──▶ src/subscription.ts ──▶ scripts/check.ts ──▶ scripts/build.ts ──▶ dist/gkd.json5 +src/categories.ts ──┘ (defineGkdSubscription) (checkSubscription) (updateDist + updateReadMeMd) +``` + +- `src/subscription.ts` — 入口文件。调用 `batchImportApps()` 自动导入 `src/apps/` 下所有 `.ts` 文件,通过 `defineGkdSubscription()` 组装订阅对象。 +- `src/apps/` — 每个 Android 应用一个 `.ts` 文件,以包名命名(如 `com.tencent.mm.ts`)。导出 `defineGkdApp()`,包含 `id`、`name` 和 `groups[]`。 +- `src/categories.ts` — 定义规则分类(开屏广告、青少年模式、更新提示等),包含 `key`、`name` 和默认 `enable` 状态。规则组名称**必须**以分类名称开头(如 `分段广告-xxx`)。 +- `src/globalGroups.ts` — 跨应用的全局规则(跳过开屏广告、更新提示、青少年模式)。使用 `src/globalDefaultApps.ts` 中的黑白名单。 +- `scripts/check.ts` — 通过 `@gkd-kit/tools` 验证订阅和 API 版本。 +- `scripts/build.ts` — 构建 `dist/gkd.json5`、`dist/README.md`,并从 `Template.md` 更新根目录 `README.md`。 + +## 关键依赖 + +| 包名 | 用途 | +| ----------------- | --------------------------------------------------------------------------------------- | +| `@gkd-kit/define` | `defineGkdApp`、`defineGkdSubscription`、`defineGkdCategories`、`defineGkdGlobalGroups` | +| `@gkd-kit/api` | TypeScript 类型(`RawApp`、`RawAppGroup` 等) | +| `@gkd-kit/tools` | `batchImportApps`、`checkSubscription`、`checkApiVersion`、`updateDist` | + +## 规则结构 + +每个应用规则文件遵循以下模式: + +```ts +import { defineGkdApp } from '@gkd-kit/define'; + +export default defineGkdApp({ + id: 'com.example.app', // Android 包名 + name: '应用名称', + groups: [ + { + key: 0, + name: '分段广告-具体描述', // 必须以 categories.ts 中的分类名称开头 + activityIds: ['com.example.Activity'], // 可选:限制特定 Activity + rules: [ + { + key: 0, + name: '步骤 1 描述', + matches: '[选择器语法]', // GKD 选择器(类 CSS 语法) + snapshotUrls: ['https://i.gkd.li/i/...'], // 必填:用于维护的快照链接 + }, + ], + }, + ], +}); +``` + +## 选择器语法 + +GKD 选择器使用类 CSS 语法匹配 Android 视图节点。常用模式: + +| 语法 | 说明 | 示例 | +| --------------------------- | -------------- | ------------------------------------ | +| `[text="精确文本"]` | 按文本内容匹配 | `[text="跳过广告"]` | +| `[text*="包含"]` | 子字符串匹配 | `[text*="跳过"]` | +| `[id="com.example:id/btn"]` | 按资源 ID 匹配 | `[id="com.tencent.mm:id/close"]` | +| `[vid="mainId"]` | 按子 ID 匹配 | `[vid="btn_skip"]` | +| `[clickable=true]` | 按属性匹配 | `[clickable=true]` | +| `@Node > [text="Child"]` | 关系选择器 | `@FrameLayout > [text="关闭"]` | +| `[visibleToUser=true]` | 可见性约束 | `[visibleToUser=true][text*="广告"]` | + +详见 [GKD API 文档](https://gkd.li/api/) 和 [选择器参考](../../docs/Selectors.md) diff --git a/.claude/rules/ci-cd.md b/.claude/rules/ci-cd.md new file mode 100644 index 000000000..1f7aa3a3c --- /dev/null +++ b/.claude/rules/ci-cd.md @@ -0,0 +1,337 @@ +# CI/CD 工作流规范 + +> 本文件合并自 `.trae/rules/project_rules.md`,为 Issue 自动审核工作流的完整设计规范。 + +## 核心架构:Orchestrator + Worker + +``` +GitHub Actions (.yml) = Orchestrator(编排器) +Python (scripts/python/) = Worker(分析器) +``` + +两者职责**严格分离**。 + +## GitHub Actions (.yml) 职责 + +- Workflow 触发与权限声明(`contents: read` + `issues: write`) +- Job / Step 编排与条件分支(if) +- 环境准备(checkout、setup-python、标签预创建) +- 标签操作(gh CLI,标签在 analyze Job 中预创建) +- 评论操作(find-comment + create-or-update-comment) +- Issue 关闭 / 重新打开(gh CLI) +- 读取 Python 输出,决定执行哪些 Job +- Recovery 场景清理残留警告评论(gh api DELETE) + +**原则:GitHub Actions 能完成的事,不允许放进 Python。** + +## Python 职责 + +- Markdown 文本解析与正则匹配 +- URL 提取与分类 +- HTTP 网络请求(HEAD / GET+Range) +- GKD 分享链接 → GH 附件 URL 转换(用于网络检查) +- GitHub 附件 → GKD 代理链接转换(用于 Bot 评论) +- Markdown 评论内容生成 +- 结果输出到 GITHUB_OUTPUT + +**Python 禁止:** + +- 调用 GitHub REST API +- 打标签 / 移除标签 +- 发表 / 更新评论 +- 关闭 / 打开 Issue +- 任何 GitHub 状态修改 + +--- + +## 工作流业务流程(多 Job 架构) + +``` +1. analyze + - 合并 Issue Body + 评论内容 + - issue_comment 仅处理作者评论 + | + +-- has_snapshot == 'false' + | └─> handle-missing-snapshot: 标签+评论+关闭 (阻断后续所有 Job) + | + +-- has_snapshot == 'true' + | + +-- has_unreachable == 'true' + | └─> handle-unreachable-snapshot: 标签+评论 (不关闭, 不阻断后续) + | + +-- network_status 分支 (并行): + | +-- '404' ──> handle-network-404: 标签+评论 (不关闭, 阻断转换) + | +-- 'uncertain' ──> handle-network-uncertain: 标签+评论 (不关闭, 阻断转换) + | +-- 'ok' ──> (无动作, 继续后续) + | + +-- has_convertible == 'true' (仅当 404/uncertain 均 skipped) + | └─> handle-convert: Bot 评论 + | + └─> warning_type == 'recovery' (仅当 missing-skipped + 全部检查通过) + └─> handle-recovery: 移除标签+重新打开+恢复评论+清理残留 +``` + +## Job 划分方案 + +| Job 名称 | 依赖 | 触发条件 | 动作 | +| ----------------------------- | ----------------------------------- | ----------------------------------------------------- | -------------------------------------- | +| `analyze` | 无 | 始终执行(issue_comment 仅处理作者评论) | 运行 Python 分析 + 预创建标签 | +| `handle-missing-snapshot` | analyze | `has_snapshot == 'false'` | 标签 + 评论 + 关闭(阻断后续所有 Job) | +| `handle-unreachable-snapshot` | analyze + handle-missing-snapshot | missing-skipped && `has_unreachable == 'true'` | 标签 + 评论(不关闭,不阻断后续) | +| `handle-network-404` | analyze + handle-missing-snapshot | missing-skipped && `network_status == '404'` | 标签 + 评论(不关闭,阻断转换) | +| `handle-network-uncertain` | analyze + handle-missing-snapshot | missing-skipped && `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭,阻断转换) | +| `handle-convert` | analyze + missing + 404 + uncertain | missing/404/uncertain 均 skipped && `has_convertible` | Bot 评论 | +| `handle-recovery` | analyze + 所有上述 Job | missing-skipped && `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | + +### 多种警告可共存 + +不可访问快照和 404/不确定可以同时触发,各自打标签和发评论。 +只有缺失快照是唯一致命场景(关闭 Issue)。 + +--- + +## 关键设计决策 + +### 1. Python 只运行一次 + +Python 脚本只在 `analyze` Job 中执行一次,输出所有原子化布尔标志和按场景独立的评论内容。 +各处理 Job 根据这些标志决定是否执行。 + +**原因:** 减少 setup 开销,避免重复解析 Issue Body。 + +### 2. Fail Fast 原则 + +网络检查遇到第一个 404 立即停止,不发后续请求。 + +**原因:** 节省网络请求和运行时间,审核类工作流不需要完整报告。 + +### 3. 幂等性(Idempotent) + +每次 opened / edited / issue_comment 触发都完全重跑全流程,保证最终状态一致。 + +**原因:** 避免遗留旧标签或旧评论,行为可预测。 + +### 4. 评论防刷屏 + +使用 `peter-evans/find-comment@v4` 按场景独立标记查找已有评论 ID, +再用 `peter-evans/create-or-update-comment@v5` + `comment-id` + `edit-mode: replace` 更新,而非重复创建。 + +每个场景使用独立的 HTML 标记: + +- `` — 缺失快照 +- `` — 不可访问快照 +- `` — 链接 404 +- `` — 网络不确定 +- `` — 编辑/评论恢复 +- `` — Bot 转换评论 + +恢复场景使用 `` 标记) | +| `comment_unreachable` | string | 不可访问快照评论 Markdown(含 `` 标记) | +| `comment_404` | string | 链接 404 评论 Markdown(含 `` 标记) | +| `comment_uncertain` | string | 网络不确定评论 Markdown(含 `` 标记) | +| `comment_recovery` | string | 恢复评论 Markdown(含 `` 标记) | +| `comment_bot` | string | Bot 评论 Markdown(含 `` 标记) | + +--- + +## 标签定义 + +| 场景 | 标签名 | 是否关闭 Issue | +| ------------------------- | ---------------------------------- | ---------------------- | +| 缺失快照 | `缺失快照(missing-snapshot)` | ✅ 关闭(not planned) | +| 不可访问快照链接 | `需补充链接(need-supplement-link)` | ❌ 不关闭 | +| 链接无法访问(404/403/5xx) | `链接无法访问(inaccessible-link)` | ❌ 不关闭 | + +--- + +## 链接识别规则 + +| 类型 | 匹配模式 | 分类 | +| ------------ | ----------------------------------------------- | ---------------------- | +| GKD 分享链接 | `https://i.gkd.li/i/\d+` | `gkd` | +| GitHub 附件 | `https://github.com/user-attachments/files/...` | `github_attachment` | +| 不可访问快照 | `https://i.gkd.li/snapshot/...` | `unreachable_snapshot` | + +--- + +## 链接转换规则 + +### Bot 评论转换(GitHub 附件 → GKD 代理链接) + +- 仅转换 `github_attachment` 类型链接 +- 转换公式:`https://i.gkd.li/i?url={{原始GitHub附件URL}}` +- GKD 链接原样保留,不转换 + +### 网络检查转换(GKD 分享链接 → GH 附件 URL) + +- 仅用于网络可访问性检查,不影响 Bot 评论输出 +- 转换公式:`https://i.gkd.li/i/{id}` → `https://github.com/user-attachments/files/{id}/file.zip` +- `{id}` 为 GKD 链接中的数字部分,`file.zip` 为固定占位符 + +--- + +## Bot 评论格式 + +### 快照解析策略 + +- 下载 zip 到内存,解压读取 snapshot.json +- 同 Activity 只下载一个代表快照,其余只记录链接 +- GKD 分享链接先转 GH 附件 URL 再下载解析 +- 下载失败时仍作为可转换链接保留 + +### 主区域(直接可见) + +``` +## AppName `appId` versionName +device_model · Android release · GKD version + +**Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 +[snapshot_id](converted_url) + +**GKD 链接** +[display](url) · [display](url) +``` + +信息分层: + +- App 标题:appName + appId + appVersionName +- App 副标题:device_model + Android版本 + GKD版本(同App只显示一次) +- Activity 行:activityId(取最后一段) + 快查ID/Text数 + 最大深度 + 可点击数 + 总节点数 +- 链接行:snapshot_id 链接到 GKD 代理 URL + +### 折叠区(详细信息) + +- 按 App 分组的详细信息表:可见节点 / 分辨率 / 方向 / appVersionCode / GKD版本号+构建号 / userId +- 设备信息表(去重):代号 / 型号 / 制造商 / 品牌 / SDK / Android + +--- + +## 网络检查策略 + +1. 优先 HEAD 请求(最快,只获取响应头) +2. HEAD 返回 405 时回退到 GET + Range 头(只请求前 1 字节) +3. 超时时间:20 秒 +4. 404 → 确认不可访问(非致命,不关闭 Issue) +5. 403 / 5xx → 不确定,折叠展示错误详情 +6. 3xx → 跟随重定向,以最终状态码为准 +7. GKD 分享链接先转换为 GH 附件 URL 再检查 + +--- + +## 使用的 GitHub Actions + +| Action | 用途 | +| ----------------------------------------- | --------------------------- | +| `actions/checkout@v7` | 拉取仓库代码 | +| `actions/setup-python@v6` | 初始化 Python 环境 | +| `peter-evans/find-comment@v4` | 查找已有评论(按作者+内容) | +| `peter-evans/create-or-update-comment@v5` | 发布/更新评论(防刷屏) | +| `gh` CLI(内置) | 标签操作、关闭/打开 Issue | + +--- + +## Python 模块结构 + +``` +scripts/python/ + ├── core/ # 核心功能层 + │ ├── extractor.py # 链接提取与分类 + │ ├── checker.py # 网络检查 + │ ├── converter.py # 链接转换 + │ └── snapshot_parser.py # 快照解析 + ├── utils/ # 工具模块层 + │ ├── models.py # 数据结构定义 + │ ├── common.py # 通用工具函数 + │ └── utils.py # GITHUB_OUTPUT 工具 + ├── api/ # 高层 API 层 + │ └── link_checker.py # 可复用的链接检查器 + ├── entry/ # 入口脚本层 + │ └── check_issue.py # Issue 场景主入口 + ├── tests/ # 测试层 + │ ├── verify.py # 本地验证脚本 + │ └── test_scenarios.json # 测试场景配置 + ├── formatter.py # 评论格式化(跨层使用) + └── README.md # 模块说明文档 +``` + +### 模块化要求 + +- 每个文件职责单一 +- 禁止互相重复代码 +- 禁止一个几百行的大脚本 +- 每个文件顶部说明用途 +- 每个函数必须有注释 +- 复杂逻辑必须有注释 + +## Python 脚本说明 + +- `entry/check_issue.py` — Issue 场景主入口,分析快照链接并输出结果 +- `core/snapshot_parser.py` — 下载并解析快照 zip 文件 +- `formatter.py` — 生成 Bot 评论的 Markdown 内容 +- `core/converter.py` — GitHub 附件 → GKD 代理链接转换 +- `api/link_checker.py` — 可复用的链接检查 API(可在其他 CI 中使用) + +详细说明见 `scripts/python/README.md` diff --git a/.claude/rules/conventions.md b/.claude/rules/conventions.md new file mode 100644 index 000000000..32c20a12a --- /dev/null +++ b/.claude/rules/conventions.md @@ -0,0 +1,75 @@ +# 编码规范与项目约定 + +## 自动提交规范 + +每当你完成一个独立功能的开发,或修复完一个 Bug 并验证通过后,请自动运行 `git commit` 提交代码。 + +**触发条件:** + +- 完成一个独立功能的开发 +- 修复完一个 Bug 并验证通过 +- 重构完成并确认功能正常 + +**Commit message 格式:** + +``` +<摘要:一句话概括改动内容> + +- 具体改动 1 +- 具体改动 2 +``` + +- 使用中文描述 +- 摘要简洁明了,一句话概括 +- 描述详细但简洁,列出具体做了什么 + +**示例:** + +``` +修复 Python 脚本编码问题 + +- 将 extractor.py 中的 UTF-8 编码声明移到文件顶部 +- 修复 checker.py 中中文字符导致的 UnicodeDecodeError +- 统一所有脚本使用 utf-8 编码 +``` + +### 提交前检查 + +- 提交前必须保证本地测试全部通过,禁止跳过任何测试 + +## PR 约束 + +PR 检查强制要求每次 PR **最多修改 1 个订阅源文件**(即仅允许修改一个 `src/apps/*.ts`、`src/categories.ts`、`src/globalGroups.ts` 或 `src/subscription.ts`)。 + +## Git 钩子 + +通过 `simple-git-hooks` + `lint-staged` 实现: + +- **pre-commit**:对暂存的 `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs` 文件执行 ESLint + Prettier;对 `.json` 文件执行 Prettier +- **commit-msg**:commitlint(遵循 conventional commits,详见 `commitlint.config.ts`) +- **pre-push**:`pnpm run check` + +## 代码风格 + +### TypeScript + +- 遵循 ESLint + Prettier 配置 +- 使用 `@gkd-kit/define` 提供的类型安全 API + +### Python + +- 文件使用 UTF-8 编码 +- 类型注解(Python 3.10+ 语法) +- dataclass 用于数据结构 +- 不使用第三方库,仅使用 Python 标准库 +- YAML Step 名称使用中文 + +## 构建输出 + +| 文件 | 说明 | +| ------------------------ | ------------------------------------------------- | +| `dist/gkd.json5` | GKD 应用消费的主订阅文件 | +| `dist/README.md` | 自动生成的应用/规则数量摘要 | +| `dist/gkd.version.json5` | 版本跟踪 | +| `dist/CHANGELOG.md` | 自动生成的变更日志 | +| 根目录 `README.md` | 构建时从 `Template.md` 重新生成,包含当前统计数据 | diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..7afd71225 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm run check)", + "Bash(pnpm run build)", + "Bash(pnpm run lint)", + "Bash(pnpm run format)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git reset *)", + "Bash(git rm *)", + "Bash(git status)", + "Bash(git log *)", + "Bash(git diff *)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 46d1dc16a..c2c256e61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,209 +1,50 @@ # CLAUDE.md -本文件为 Claude Code (claude.ai/code) 提供代码协作指导。 - ## 项目概述 -GKD 订阅规则仓库 — 为 [GKD](https://gkd.li/) 提供第三方订阅规则。GKD 是一款基于 Android 无障碍服务的工具,可自动关闭广告、弹窗和不需要的 UI 元素。规则以 TypeScript 文件编写,定义 UI 节点选择器来匹配 Android 视图层级快照。 +GKD 订阅规则仓库 — 为 [GKD](https://gkd.li/) 提供第三方订阅规则(TypeScript)。`scripts/python/` 包含 GitHub Issue 自动审核工具(Python)。 + +## 技术栈 + +- **规则定义**: TypeScript + `@gkd-kit/define` +- **构建**: pnpm + tsx +- **CI**: GitHub Actions + Python 3.10+(标准库,无第三方依赖) +- **验证**: `@gkd-kit/tools` ## 开发命令 ```bash pnpm install # 安装依赖 -pnpm run check # TypeScript 类型检查 + 订阅验证(选择器语法、规则结构) -pnpm run build # TypeScript 类型检查 + 构建 dist/gkd.json5 + 更新 dist/README.md 和根目录 README.md -pnpm run lint # ESLint 自动修复(移除未使用的导入、Prettier 格式化) -pnpm run format # Prettier 格式化所有源文件 +pnpm run check # 类型检查 + 订阅验证 +pnpm run build # 构建 dist/gkd.json5 + 更新 README +pnpm run lint # ESLint 自动修复 +pnpm run format # Prettier 格式化 ``` -**单文件验证**:没有单文件检查模式。`pnpm run check` 验证整个订阅树。修改规则后请运行此命令。 - -**Git 钩子**(通过 `simple-git-hooks` + `lint-staged`): -- pre-commit:对暂存的 `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs` 文件执行 ESLint + Prettier;对 `.json` 文件执行 Prettier -- commit-msg:commitlint(遵循 conventional commits,详见 `commitlint.config.ts`) -- pre-push:`pnpm run check` - -## 自动提交规范 - -每当你完成一个独立功能的开发,或修复完一个 Bug 并验证通过后,请自动运行 `git commit` 提交代码,并生成一句简洁的中文 commit message。 +## 禁止行为 -**触发条件:** -- 完成一个独立功能的开发 -- 修复完一个 Bug 并验证通过 -- 重构完成并确认功能正常 +- ❌ 不要安装新依赖除非明确要求 +- ❌ 不要修改 `.env` 文件 +- ❌ 不要绕过 `pnpm run check` 直接提交 -**Commit message 格式:** -- 使用中文描述 -- 简洁明了,一句话概括改动内容 -- 示例:`修复 Python 脚本编码问题`、`新增链接检查验证工具`、`优化模块依赖结构` - -### 提交前检查 -- 在提交前必须保证已在本地完成此次commit所有测试!禁止使用简易测试替代此次提交的所有可能发生的事件略过,必须考虑周到且测试通过后方可commit - -## 架构设计 - -### 核心流程 +## 目录结构 ``` -src/apps/*.ts ──┐ -src/globalGroups.ts ──┤──▶ src/subscription.ts ──▶ scripts/check.ts ──▶ scripts/build.ts ──▶ dist/gkd.json5 -src/categories.ts ──┘ (defineGkdSubscription) (checkSubscription) (updateDist + updateReadMeMd) -``` - -- `src/subscription.ts` — 入口文件。调用 `batchImportApps()` 自动导入 `src/apps/` 下所有 `.ts` 文件,通过 `defineGkdSubscription()` 组装订阅对象。 -- `src/apps/` — 每个 Android 应用一个 `.ts` 文件,以包名命名(如 `com.tencent.mm.ts`)。导出 `defineGkdApp()`,包含 `id`、`name` 和 `groups[]`。 -- `src/categories.ts` — 定义规则分类(开屏广告、青少年模式、更新提示等),包含 `key`、`name` 和默认 `enable` 状态。规则组名称**必须**以分类名称开头(如 `分段广告-xxx`)。 -- `src/globalGroups.ts` — 跨应用的全局规则(跳过开屏广告、更新提示、青少年模式)。使用 `src/globalDefaultApps.ts` 中的黑白名单。 -- `scripts/check.ts` — 通过 `@gkd-kit/tools` 验证订阅和 API 版本。 -- `scripts/build.ts` — 构建 `dist/gkd.json5`、`dist/README.md`,并从 `Template.md` 更新根目录 `README.md`。 - -### 关键依赖 - -- `@gkd-kit/define` — `defineGkdApp`、`defineGkdSubscription`、`defineGkdCategories`、`defineGkdGlobalGroups` -- `@gkd-kit/api` — TypeScript 类型(`RawApp`、`RawAppGroup` 等) -- `@gkd-kit/tools` — `batchImportApps`、`checkSubscription`、`checkApiVersion`、`updateDist` - -### 规则结构 - -每个应用规则文件遵循以下模式: -```ts -import { defineGkdApp } from '@gkd-kit/define'; - -export default defineGkdApp({ - id: 'com.example.app', // Android 包名 - name: '应用名称', - groups: [ - { - key: 0, - name: '分段广告-具体描述', // 必须以 categories.ts 中的分类名称开头 - activityIds: ['com.example.Activity'], // 可选:限制特定 Activity - rules: [ - { - key: 0, - name: '步骤 1 描述', - matches: '[选择器语法]', // GKD 选择器(类 CSS 语法) - snapshotUrls: ['https://i.gkd.li/i/...'], // 必填:用于维护的快照链接 - }, - ], - }, - ], -}); +src/apps/*.ts # 每个 Android 应用一个规则文件(以包名命名) +src/subscription.ts # 入口:组装订阅对象 +src/categories.ts # 规则分类定义 +src/globalGroups.ts # 跨应用全局规则 +scripts/python/ # Issue 自动审核工具 +scripts/*.ts # 构建/检查脚本 +dist/ # 构建输出 ``` -### 选择器语法 - -GKD 选择器使用类 CSS 语法匹配 Android 视图节点。常用模式: -- `[text="精确文本"]` — 按文本内容匹配 -- `[text*="包含"]` — 子字符串匹配 -- `[id="com.example:id/btn"]` — 按资源 ID 匹配 -- `[vid="viewId"]` — 按视图 ID 匹配 -- `[clickable=true]` — 按属性匹配 -- `@Node > [text="Child"]` — 关系选择器(子节点、兄弟节点、父节点) -- `[visibleToUser=true]` — 可见性约束 -- 详见 [GKD API 文档](https://gkd.li/api/) 和 [选择器参考](./docs/Selectors.md) - -## PR 约束 - -PR 检查强制要求每次 PR **最多修改 1 个订阅源文件**(即仅允许修改一个 `src/apps/*.ts`、`src/categories.ts`、`src/globalGroups.ts` 或 `src/subscription.ts`)。 - -## CI 工作流规范 - -本项目包含 GitHub Actions 工作流,用于自动审核用户提交的 Issue 内容。以下为设计规范: - -### 核心架构:Orchestrator + Worker - -``` -GitHub Actions (.yml) = Orchestrator(编排器) -Python (scripts/python/) = Worker(分析器) -``` - -两者职责**严格分离**。 - -#### GitHub Actions 职责 - -- Workflow 触发与权限声明(`contents: read` + `issues: write`) -- Job / Step 编排与条件分支(if) -- 环境准备(checkout、setup-python、标签预创建) -- 标签操作(gh CLI) -- 评论操作(find-comment + create-or-update-comment) -- Issue 关闭 / 重新打开(gh CLI) -- 读取 Python 输出,决定执行哪些 Job - -**原则:GitHub Actions 能完成的事,不允许放进 Python。** - -#### Python 职责 - -- Markdown 文本解析与正则匹配 -- URL 提取与分类 -- HTTP 网络请求(HEAD / GET+Range) -- GKD 分享链接 → GH 附件 URL 转换 -- GitHub 附件 → GKD 代理链接转换 -- Markdown 评论内容生成 -- 结果输出到 GITHUB_OUTPUT - -**Python 禁止:** -- 调用 GitHub REST API -- 打标签 / 移除标签 -- 发表 / 更新评论 -- 关闭 / 打开 Issue -- 任何 GitHub 状态修改 - -### 关键设计决策 - -1. **Python 只运行一次** — 在 `analyze` Job 中执行一次,输出所有原子化布尔标志,各处理 Job 根据标志决定是否执行 -2. **Fail Fast 原则** — 网络检查遇到第一个 404 立即停止,不发后续请求 -3. **幂等性** — 每次触发都完全重跑全流程,保证最终状态一致 -4. **评论防刷屏** — 使用 `find-comment` 按场景独立标记查找已有评论,更新而非重复创建 -5. **多 Job 架构** — 每个 Job 对应一个明确的业务节点,而非线性流水线 -6. **标签预创建** — 在 `analyze` Job 中预创建所有所需标签,确保后续操作不会因标签不存在而失败 - -### Python 模块结构 - -``` -scripts/python/ - ├── core/ # 核心功能层 - │ ├── extractor.py # 链接提取与分类 - │ ├── checker.py # 网络检查 - │ ├── converter.py # 链接转换 - │ └── snapshot_parser.py # 快照解析 - ├── utils/ # 工具模块层 - │ ├── models.py # 数据结构定义 - │ ├── common.py # 通用工具函数 - │ └── utils.py # GITHUB_OUTPUT 工具 - ├── api/ # 高层 API 层 - │ └── link_checker.py # 可复用的链接检查器 - ├── entry/ # 入口脚本层 - │ └── check_issue.py # Issue 场景主入口 - ├── tests/ # 测试层 - │ ├── verify.py # 本地验证脚本 - │ └── test_scenarios.json # 测试场景配置 - ├── formatter.py # 评论格式化(跨层使用) - └── README.md # 模块说明文档 -``` - -**模块化要求:** -- 每个文件职责单一 -- 禁止互相重复代码 -- 禁止一个几百行的大脚本 -- 每个文件顶部说明用途 -- 每个函数必须有注释 - -## Python 脚本 - -`scripts/python/` 包含 GitHub Issue 自动化工具,按职责分层组织: - -- `entry/check_issue.py` — Issue 场景主入口,分析快照链接并输出结果 -- `core/snapshot_parser.py` — 下载并解析快照 zip 文件 -- `formatter.py` — 生成 Bot 评论的 Markdown 内容 -- `core/converter.py` — GitHub 附件 → GKD 代理链接转换 -- `api/link_checker.py` — 可复用的链接检查 API(可在其他 CI 中使用) - -详细说明见 `scripts/python/README.md` +## 上下文加载策略 -## 构建输出 +详细文档按需加载,不要全部塞入上下文: -- `dist/gkd.json5` — GKD 应用消费的主订阅文件 -- `dist/README.md` — 自动生成的应用/规则数量摘要 -- `dist/gkd.version.json5` — 版本跟踪 -- `dist/CHANGELOG.md` — 自动生成的变更日志 -- 根目录 `README.md` 在构建时从 `Template.md` 重新生成,包含当前统计数据 +| 场景 | 加载文件 | +| --------------------- | ------------------------------- | +| 编写/修改订阅规则 | `.claude/rules/architecture.md` | +| 维护 CI / Python 脚本 | `.claude/rules/ci-cd.md` | +| 编码规范 / 提交规范 | `.claude/rules/conventions.md` | From d9402cafa7da251e3c5184c05ca91c0f00655a7b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 03:00:42 +0800 Subject: [PATCH 47/90] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=90=8C?= =?UTF-8?q?=E4=B8=80=20Activity=20=E5=A4=9A=E4=B8=AA=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=98=BE=E7=A4=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 _parse_all_snapshots,保留同 Activity 的所有快照到 snapshots 列表 - 修改 _group_by_app,收集每个 Activity 的所有快照链接 - 修改 _render_activity_line,在 Activity 组内显示所有快照链接 - 删除不再需要的 _get_activity_links 函数 --- scripts/python/entry/check_issue.py | 24 +++-------- scripts/python/formatter.py | 66 ++++++++++++++--------------- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index fdbd057cb..775fe5ad1 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -240,10 +240,10 @@ def _check_all_links(links: list) -> _NetworkCheckResult: def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: """ - 下载并解析所有快照链接,同 Activity 只下载一个代表。 + 下载并解析所有快照链接,同 Activity 的所有快照都保留。 返回: - - snapshots:解析成功的 SnapshotInfo 列表 + - snapshots:解析成功的 SnapshotInfo 列表(同 Activity 的所有快照都在) - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] """ from core.converter import GKD_PROXY_TEMPLATE @@ -251,9 +251,6 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st snapshots: list[SnapshotInfo] = [] gkd_links: list[tuple[str, str]] = [] - # 已下载的 Activity 集合,用于去重 - seen_activities: set[str] = set() - # 先处理 GitHub 附件链接 for lnk in links: if lnk.kind != "github_attachment": @@ -267,13 +264,8 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st gkd_links.append((lnk.display_text or _extract_filename(lnk.url), converted_url)) continue - act_key = f"{snap.app_id}|{snap.activity_id}" - if act_key in seen_activities: - # 同 Activity 已有代表,只记录链接 - gkd_links.append((snap.snapshot_id or _extract_filename(lnk.url), converted_url)) - else: - seen_activities.add(act_key) - snapshots.append(snap) + # 所有成功解析的快照都添加到 snapshots 列表 + snapshots.append(snap) # 再处理 GKD 分享链接(GKD 链接原样保留,不套代理模板) for lnk in links: @@ -290,12 +282,8 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st gkd_links.append((lnk.display_text or lnk.url, lnk.url)) continue - act_key = f"{snap.app_id}|{snap.activity_id}" - if act_key in seen_activities: - gkd_links.append((snap.snapshot_id or lnk.url, lnk.url)) - else: - seen_activities.add(act_key) - snapshots.append(snap) + # 所有成功解析的快照都添加到 snapshots 列表 + snapshots.append(snap) return snapshots, gkd_links diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index 6e822aba6..d95710a98 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -79,7 +79,7 @@ def build_bot_comment(snapshots: list[SnapshotInfo], gkd_links: list[tuple[str, 折叠区:App 详细信息表 + 设备信息表 参数: - - snapshots:解析成功的 SnapshotInfo 列表(按 Activity 去重后) + - snapshots:解析成功的 SnapshotInfo 列表(同 Activity 的所有快照都在) - gkd_links:无法解析的 GKD 链接列表 [(display_text, converted_url), ...] """ if not snapshots and not gkd_links: @@ -87,12 +87,12 @@ def build_bot_comment(snapshots: list[SnapshotInfo], gkd_links: list[tuple[str, lines: list[str] = [] - # 按 appId 分组 - app_groups = _group_by_app(snapshots) + # 按 appId 分组,同 appId 下按 activityId 分组 + app_groups, activity_links = _group_by_app(snapshots) # 主区域:按 App 输出 for app_key, app_snapshots in app_groups.items(): - _render_app_section(lines, app_key, app_snapshots) + _render_app_section(lines, app_key, app_snapshots, activity_links) # GKD 链接(无法下载解析的) if gkd_links: @@ -116,51 +116,44 @@ def build_bot_comment(snapshots: list[SnapshotInfo], gkd_links: list[tuple[str, # ── 分组与去重 ── -def _group_by_app(snapshots: list[SnapshotInfo]) -> dict[str, list[SnapshotInfo]]: +def _group_by_app(snapshots: list[SnapshotInfo]) -> tuple[dict[str, list[SnapshotInfo]], dict[str, list[tuple[str, str]]]]: """ 按 appId 分组,同 appId 下按 activityId 分组。 - 同 activityId 只保留第一个(代表快照),其余只记录链接。 - 返回有序字典:key = "appName `appId` versionName" + 返回: + - groups:有序字典,key = "appName `appId` versionName",value = 该 App 的代表快照列表(每个 Activity 一个) + - activity_links:字典,key = "appId|activityId",value = 该 Activity 的所有快照链接 [(snapshot_id, converted_url), ...] """ from collections import OrderedDict groups: dict[str, list[SnapshotInfo]] = OrderedDict() - seen_activities: dict[str, list[SnapshotInfo]] = {} + activity_links: dict[str, list[tuple[str, str]]] = {} + seen_activities: set[str] = set() for snap in snapshots: app_key = f"{snap.app_name} `{snap.app_id}` {snap.app_version_name}" groups.setdefault(app_key, []) act_key = f"{snap.app_id}|{snap.activity_id}" + activity_links.setdefault(act_key, []) + + # 收集该 Activity 的所有快照链接 + link_display = snap.snapshot_id or snap.original_url.split("/")[-1] + link_url = snap.converted_url or snap.original_url + activity_links[act_key].append((link_display, link_url)) + + # 每个 Activity 只保留第一个快照作为代表(用于显示统计信息) if act_key not in seen_activities: - seen_activities[act_key] = [snap] + seen_activities.add(act_key) groups[app_key].append(snap) - else: - seen_activities[act_key].append(snap) - return groups - - -def _get_activity_links(snapshots: list[SnapshotInfo], activity_id: str) -> list[tuple[str, str]]: - """ - 获取同一 Activity 下所有快照的链接列表。 - - 返回 [(snapshot_id, converted_url), ...] - """ - links = [] - for snap in snapshots: - if snap.activity_id == activity_id: - display = snap.snapshot_id or snap.original_url.split("/")[-1] - url = snap.converted_url or snap.original_url - links.append((display, url)) - return links + return groups, activity_links # ── 主区域渲染 ── -def _render_app_section(lines: list[str], app_key: str, snapshots: list[SnapshotInfo]): +def _render_app_section(lines: list[str], app_key: str, snapshots: list[SnapshotInfo], activity_links: dict[str, list[tuple[str, str]]]): """ 渲染单个 App 的主区域内容。 @@ -194,14 +187,19 @@ def _render_app_section(lines: list[str], app_key: str, snapshots: list[Snapshot continue seen_activities.add(snap.activity_id) - _render_activity_line(lines, snap) + # 获取该 Activity 的所有快照链接 + act_key = f"{snap.app_id}|{snap.activity_id}" + links = activity_links.get(act_key, []) + + _render_activity_line(lines, snap, links) -def _render_activity_line(lines: list[str], snap: SnapshotInfo): +def _render_activity_line(lines: list[str], snap: SnapshotInfo, links: list[tuple[str, str]]): """ 渲染单个 Activity 行。 格式:**Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 + [id1](url) · [id2](url) """ # Activity 名称取最后一段(类名简写) act_display = short_activity_name(snap.activity_id) @@ -215,10 +213,10 @@ def _render_activity_line(lines: list[str], snap: SnapshotInfo): ) lines.append(f"**{act_display}** — {stats}") - # 链接行 - link_url = snap.converted_url or snap.original_url - link_display = snap.snapshot_id or extract_filename(snap.original_url) - lines.append(f"[{link_display}]({link_url})") + # 链接行(该 Activity 的所有快照链接) + if links: + link_parts = [f"[{dt}]({url})" for dt, url in links] + lines.append(" · ".join(link_parts)) lines.append("") From 0437d6dfb514ef4e935c08e4b3603110b67a3292 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 03:07:39 +0800 Subject: [PATCH 48/90] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=20=5Frender=5Fa?= =?UTF-8?q?pp=5Fsection=20=E7=A9=BA=E5=88=97=E8=A1=A8=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当 snapshots 列表为空时提前返回,避免 IndexError --- scripts/python/formatter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index d95710a98..f0dfaf93d 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -164,6 +164,9 @@ def _render_app_section(lines: list[str], app_key: str, snapshots: list[Snapshot **Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 [id1](url) · [id2](url) """ + if not snapshots: + return + # App 标题 lines.append(f"## {app_key}") From dc82a4fdbf5558e01fcdfc2a68375e3d7a12c88f Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 04:55:27 +0800 Subject: [PATCH 49/90] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E9=93=BE=E6=8E=A5=E6=97=B6=E4=BF=9D=E7=95=99=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YAML workflow: 添加获取历史链接 Step(优先读取旧 Bot 评论,fallback 获取所有评论) - extractor.py: 添加 extract_links_from_bot_comment 函数 - check_issue.py: 合并历史链接 + 新链接,去重后生成 Bot 评论 解决 Issue 评论补充链接后历史快照丢失的问题 --- .github/workflows/issue_content_check.yml | 29 +++++++++ scripts/python/core/extractor.py | 51 ++++++++++++++++ scripts/python/entry/check_issue.py | 74 ++++++++++++++++++++--- 3 files changed, 147 insertions(+), 7 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 3bd69b591..00748b3b2 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -81,6 +81,33 @@ jobs: gh label create "需补充链接(need-supplement-link)" --color "f39c12" --description "Issue包含不可访问的快照链接" --force gh label create "链接无法访问(inaccessible-link)" --color "c0392b" --description "Issue中的链接无法访问" --force + # issue_comment 事件时,获取旧 Bot 评论或所有评论作为历史链接来源 + - name: 获取历史链接 + id: get-history + if: github.event_name == 'issue_comment' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + # 1. 尝试读取旧 Bot 评论(已解析成功的快照链接) + old_bot=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body' 2>/dev/null | head -1) + + if [ -n "$old_bot" ]; then + echo "history_source=old_bot" >> "$GITHUB_OUTPUT" + echo "history_content<> "$GITHUB_OUTPUT" + echo "$old_bot" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + # 2. Fallback: 获取所有评论 + all_comments=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ + --jq '.[].body' 2>/dev/null | sed ':a;N;$!ba;s/\n/\\n/g') + echo "history_source=all_comments" >> "$GITHUB_OUTPUT" + echo "history_content<> "$GITHUB_OUTPUT" + echo "$all_comments" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + fi + - name: 分析 Issue 快照链接 id: analyze env: @@ -89,6 +116,8 @@ jobs: ISSUE_USER: ${{ github.event.issue.user.login }} ISSUE_ACTION: ${{ github.event_name == 'issue_comment' && 'comment' || github.event.action }} SKIP_ANALYSIS: ${{ github.event_name == 'issue_comment' && steps.pre-check.outputs.needs_analysis == 'false' }} + HISTORY_CONTENT: ${{ steps.get-history.outputs.history_content || '' }} + HISTORY_SOURCE: ${{ steps.get-history.outputs.history_source || '' }} run: | if [ "$SKIP_ANALYSIS" = "true" ]; then cat >> "$GITHUB_OUTPUT" < list[LinkInfo]: seen.add(url) results.append(LinkInfo(url=url, kind=kind, display_text="")) + return results + + +# ── 从旧 Bot 评论中提取链接 ── + + +def extract_links_from_bot_comment(comment: str) -> list[LinkInfo]: + """ + 从旧 Bot 评论中提取快照链接。 + + 旧 Bot 评论格式: + - [snapshot_id](url) 格式的链接(GitHub 附件代理链接) + - https://i.gkd.li/i/数字 格式的 GKD 链接(纯文本) + + 返回:LinkInfo 列表,kind 统一为 "gkd"(用于后续处理) + """ + if not comment: + return [] + + seen: set[str] = set() + results: list[LinkInfo] = [] + + # 提取 [snapshot_id](url) 格式的链接 + for match in _RE_BOT_SNAPSHOT_LINK.finditer(comment): + snapshot_id = match.group(1) + url = match.group(2) + if url not in seen: + seen.add(url) + # 将代理链接转换为 GKD 分享链接格式 + gkd_url = f"https://i.gkd.li/i/{snapshot_id}" + results.append(LinkInfo(url=gkd_url, kind="gkd", display_text=snapshot_id)) + + # 提取纯文本 GKD 链接 + for match in _RE_BOT_GKD_LINK.finditer(comment): + url = match.group(0) + if url not in seen: + seen.add(url) + # 从 URL 中提取 ID + snapshot_id = url.split("/")[-1] + results.append(LinkInfo(url=url, kind="gkd", display_text=snapshot_id)) + return results \ No newline at end of file diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index 775fe5ad1..4ee67ad84 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -40,7 +40,7 @@ from utils.models import LinkInfo, SnapshotInfo from utils.common import extract_filename -from core.extractor import extract_links +from core.extractor import extract_links, extract_links_from_bot_comment from core.checker import ( check_unreachable_links, check_network_links, @@ -87,13 +87,37 @@ def main(): comment_body = os.environ.get("ISSUE_COMMENT_BODY", "") or "" issue_user = os.environ.get("ISSUE_USER", "") issue_action = os.environ.get("ISSUE_ACTION", "") + history_content = os.environ.get("HISTORY_CONTENT", "") or "" + history_source = os.environ.get("HISTORY_SOURCE", "") or "" - # 当评论事件时,只分析评论内容(以最新评论为主) - # 当 opened/edited 事件时,分析 Issue Body + # 当评论事件时,合并历史链接 + 新评论链接 + # 当 opened/edited 事件时,只分析 Issue Body if issue_action == "comment" and comment_body: - full_text = comment_body + # 提取新评论中的链接 + new_links = extract_links(comment_body) + + # 提取历史链接 + history_links: list[LinkInfo] = [] + if history_content: + if history_source == "old_bot": + # 从旧 Bot 评论中提取快照链接 + history_links = extract_links_from_bot_comment(history_content) + else: + # 从所有评论中提取链接 + history_links = extract_links(history_content) + + # 合并去重:历史链接 + 新链接 + # 使用 URL 作为去重键,保留首次出现的链接 + all_links = _merge_links_dedup(history_links, new_links) + + # 用于后续处理的链接列表 + links = all_links + + # 用于检查缺失快照的文本(合并后的内容) + full_text = _build_full_text_from_links(links) else: full_text = body + links = extract_links(full_text) has_snapshot = "true" has_unreachable = "false" @@ -108,9 +132,6 @@ def main(): comment_recovery = "" comment_bot = "" - # ── 第一步:提取所有链接 ── - links = extract_links(full_text) - # ── 第二步:判断是否缺少快照(唯一致命 → 提前返回) ── has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) @@ -299,5 +320,44 @@ def _output(**kwargs): write_output(key, value) +def _merge_links_dedup(history_links: list[LinkInfo], new_links: list[LinkInfo]) -> list[LinkInfo]: + """ + 合并历史链接和新链接,基于 URL 去重。 + + 策略:保留首次出现的链接(历史链接优先) + """ + seen: set[str] = set() + result: list[LinkInfo] = [] + + # 先添加历史链接(优先级高) + for lnk in history_links: + if lnk.url not in seen: + seen.add(lnk.url) + result.append(lnk) + + # 再添加新链接(排除已存在的) + for lnk in new_links: + if lnk.url not in seen: + seen.add(lnk.url) + result.append(lnk) + + return result + + +def _build_full_text_from_links(links: list[LinkInfo]) -> str: + """ + 从链接列表构建用于检查缺失快照的文本。 + + 格式:每行一个链接,包含显示文字和 URL + """ + parts: list[str] = [] + for lnk in links: + if lnk.display_text: + parts.append(f"[{lnk.display_text}]({lnk.url})") + else: + parts.append(lnk.url) + return "\n".join(parts) + + if __name__ == "__main__": main() \ No newline at end of file From 9dad5183f22fcc39b51e7c7c3fa36bc6b939e83e Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 05:12:15 +0800 Subject: [PATCH 50/90] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E9=93=BE=E6=8E=A5=E6=97=B6=E6=8A=98=E5=8F=A0=E6=97=A7?= =?UTF-8?q?=20Bot=20=E8=AF=84=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 GraphQL minimizeComment mutation 折叠旧评论 - 标记为 OUTDATED(过时) - 仅在 issue_comment 事件时触发 --- .github/workflows/issue_content_check.yml | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 00748b3b2..d3c494214 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -293,6 +293,44 @@ jobs: needs.analyze.outputs.has_convertible == 'true' runs-on: ubuntu-latest steps: + # issue_comment 事件时,隐藏旧 Bot 评论(折叠为 OUTDATED) + - name: 获取旧 Bot 评论的 Node ID + if: github.event_name == 'issue_comment' + id: get-old-bot-node + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node_id=$(gh api graphql -f query=' + { + repository(owner: "${{ github.repository_owner }}", name: "${{ github.event.repository.name }}") { + issue(number: ${{ github.event.issue.number }}) { + comments(first: 100) { + nodes { + id + body + author { login } + } + } + } + } + }' --jq '.data.repository.issue.comments.nodes[] | select(.author.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .id' 2>/dev/null | head -1) + echo "node_id=$node_id" >> "$GITHUB_OUTPUT" + + - name: 隐藏旧 Bot 评论 + if: github.event_name == 'issue_comment' && steps.get-old-bot-node.outputs.node_id != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api graphql -f query=' + mutation MinimizeComment($subjectId: ID!, $classifier: ReportedContentClassifiers!) { + minimizeComment(input: { subjectId: $subjectId, classifier: $classifier }) { + minimizedComment { + isMinimized + minimizedReason + } + } + }' -f subjectId="${{ steps.get-old-bot-node.outputs.node_id }}" -f classifier="OUTDATED" + - name: 查找已有 Bot 评论 id: find-bot uses: peter-evans/find-comment@v4 From 205e1904c2739b82b8ec674f40cfac7c0156c206 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 05:19:42 +0800 Subject: [PATCH 51/90] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 GraphQL 注入风险:使用环境变量传递参数 - 添加 minimizeComment 所需的 repository-projects: read 权限 - 删除重复文档 .trae/rules/project_rules.md --- .github/workflows/issue_content_check.yml | 15 +- .trae/rules/project_rules.md | 341 ---------------------- 2 files changed, 10 insertions(+), 346 deletions(-) delete mode 100644 .trae/rules/project_rules.md diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index d3c494214..5ed2519dd 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -9,6 +9,7 @@ on: permissions: contents: read issues: write + repository-projects: read env: GH_REPO: ${{ github.repository }} @@ -299,11 +300,14 @@ jobs: id: get-old-bot-node env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + ISSUE_NUM: ${{ github.event.issue.number }} run: | node_id=$(gh api graphql -f query=' - { - repository(owner: "${{ github.repository_owner }}", name: "${{ github.event.repository.name }}") { - issue(number: ${{ github.event.issue.number }}) { + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { comments(first: 100) { nodes { id @@ -313,13 +317,14 @@ jobs: } } } - }' --jq '.data.repository.issue.comments.nodes[] | select(.author.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .id' 2>/dev/null | head -1) + }' -f owner="$REPO_OWNER" -f name="$REPO_NAME" -f number="$ISSUE_NUM" --jq '.data.repository.issue.comments.nodes[] | select(.author.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .id' 2>/dev/null | head -1) echo "node_id=$node_id" >> "$GITHUB_OUTPUT" - name: 隐藏旧 Bot 评论 if: github.event_name == 'issue_comment' && steps.get-old-bot-node.outputs.node_id != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_ID: ${{ steps.get-old-bot-node.outputs.node_id }} run: | gh api graphql -f query=' mutation MinimizeComment($subjectId: ID!, $classifier: ReportedContentClassifiers!) { @@ -329,7 +334,7 @@ jobs: minimizedReason } } - }' -f subjectId="${{ steps.get-old-bot-node.outputs.node_id }}" -f classifier="OUTDATED" + }' -f subjectId="$NODE_ID" -f classifier="OUTDATED" - name: 查找已有 Bot 评论 id: find-bot diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md deleted file mode 100644 index d9841ead3..000000000 --- a/.trae/rules/project_rules.md +++ /dev/null @@ -1,341 +0,0 @@ -# GKD_subscription 项目规则 - -## 项目概述 - -GKD 订阅项目,基于 Node.js + TypeScript 编写 Android 应用自动化规则。 -Issue 内容检查工作流用于自动审核用户提交的快照链接。 - ---- - -## Issue 内容检查工作流 — 架构规则 - -### 核心架构:Orchestrator + Worker - -``` -GitHub Actions (.yml) = Orchestrator(编排器) -Python (scripts/python/) = Worker(分析器) -``` - -两者职责**严格分离**。 - -### GitHub Actions (.yml) 职责 - -- Workflow 触发与权限声明(`contents: read` + `issues: write`) -- Job / Step 编排与条件分支(if) -- 环境准备(checkout、setup-python、标签预创建) -- 标签操作(gh CLI,标签在 analyze Job 中预创建) -- 评论操作(find-comment + create-or-update-comment) -- Issue 关闭 / 重新打开(gh CLI) -- 读取 Python 输出,决定执行哪些 Job -- Recovery 场景清理残留警告评论(gh api DELETE) - -**原则:GitHub Actions 能完成的事,不允许放进 Python。** - -### Python 职责 - -- Markdown 文本解析与正则匹配 -- URL 提取与分类 -- HTTP 网络请求(HEAD / GET+Range) -- GKD 分享链接 → GH 附件 URL 转换(用于网络检查) -- GitHub 附件 → GKD 代理链接转换(用于 Bot 评论) -- Markdown 评论内容生成 -- 结果输出到 GITHUB_OUTPUT - -**Python 禁止:** -- 调用 GitHub REST API -- 打标签 / 移除标签 -- 发表 / 更新评论 -- 关闭 / 打开 Issue -- 任何 GitHub 状态修改 - ---- - -## 工作流业务流程(多 Job 架构) - -``` -1. analyze - - 合并 Issue Body + 评论内容 - - issue_comment 仅处理作者评论 - | - +-- has_snapshot == 'false' - | └─> handle-missing-snapshot: 标签+评论+关闭 (阻断后续所有 Job) - | - +-- has_snapshot == 'true' - | - +-- has_unreachable == 'true' - | └─> handle-unreachable-snapshot: 标签+评论 (不关闭, 不阻断后续) - | - +-- network_status 分支 (并行): - | +-- '404' ──> handle-network-404: 标签+评论 (不关闭, 阻断转换) - | +-- 'uncertain' ──> handle-network-uncertain: 标签+评论 (不关闭, 阻断转换) - | +-- 'ok' ──> (无动作, 继续后续) - | - +-- has_convertible == 'true' (仅当 404/uncertain 均 skipped) - | └─> handle-convert: Bot 评论 - | - └─> warning_type == 'recovery' (仅当 missing-skipped + 全部检查通过) - └─> handle-recovery: 移除标签+重新打开+恢复评论+清理残留 -``` - -### Job 划分方案 - -| Job 名称 | 依赖 | 触发条件 | 动作 | -| ----------------------------- | ----------------------------------- | ----------------------------------------------------- | -------------------------------------- | -| `analyze` | 无 | 始终执行(issue_comment 仅处理作者评论) | 运行 Python 分析 + 预创建标签 | -| `handle-missing-snapshot` | analyze | `has_snapshot == 'false'` | 标签 + 评论 + 关闭(阻断后续所有 Job) | -| `handle-unreachable-snapshot` | analyze + handle-missing-snapshot | missing-skipped && `has_unreachable == 'true'` | 标签 + 评论(不关闭,不阻断后续) | -| `handle-network-404` | analyze + handle-missing-snapshot | missing-skipped && `network_status == '404'` | 标签 + 评论(不关闭,阻断转换) | -| `handle-network-uncertain` | analyze + handle-missing-snapshot | missing-skipped && `network_status == 'uncertain'` | 标签 + 折叠评论(不关闭,阻断转换) | -| `handle-convert` | analyze + missing + 404 + uncertain | missing/404/uncertain 均 skipped && `has_convertible` | Bot 评论 | -| `handle-recovery` | analyze + 所有上述 Job | missing-skipped && `warning_type == 'recovery'` | 移除标签 + 重新打开 + 评论 + 清理残留 | - -### 多种警告可共存 - -不可访问快照和 404/不确定可以同时触发,各自打标签和发评论。 -只有缺失快照是唯一致命场景(关闭 Issue)。 - ---- - -## 关键设计决策 - -### 1. Python 只运行一次 - -Python 脚本只在 `analyze` Job 中执行一次,输出所有原子化布尔标志和按场景独立的评论内容。 -各处理 Job 根据这些标志决定是否执行。 - -**原因:** 减少 setup 开销,避免重复解析 Issue Body。 - -### 2. Fail Fast 原则 - -网络检查遇到第一个 404 立即停止,不发后续请求。 - -**原因:** 节省网络请求和运行时间,审核类工作流不需要完整报告。 - -### 3. 幂等性(Idempotent) - -每次 opened / edited / issue_comment 触发都完全重跑全流程,保证最终状态一致。 - -**原因:** 避免遗留旧标签或旧评论,行为可预测。 - -### 4. 评论防刷屏 - -使用 `peter-evans/find-comment@v4` 按场景独立标记查找已有评论 ID, -再用 `peter-evans/create-or-update-comment@v5` + `comment-id` + `edit-mode: replace` 更新,而非重复创建。 - -每个场景使用独立的 HTML 标记: -- `` — 缺失快照 -- `` — 不可访问快照 -- `` — 链接 404 -- `` — 网络不确定 -- `` — 编辑/评论恢复 -- `` — Bot 转换评论 - -恢复场景使用 `` 标记) | -| `comment_unreachable` | string | 不可访问快照评论 Markdown(含 `` 标记) | -| `comment_404` | string | 链接 404 评论 Markdown(含 `` 标记) | -| `comment_uncertain` | string | 网络不确定评论 Markdown(含 `` 标记) | -| `comment_recovery` | string | 恢复评论 Markdown(含 `` 标记) | -| `comment_bot` | string | Bot 评论 Markdown(含 `` 标记) | - ---- - -## 标签定义 - -| 场景 | 标签名 | 是否关闭 Issue | -| ------------------------- | ---------------------------------- | --------------------- | -| 缺失快照 | `缺失快照(missing-snapshot)` | ✅ 关闭(not planned) | -| 不可访问快照链接 | `需补充链接(need-supplement-link)` | ❌ 不关闭 | -| 链接无法访问(404/403/5xx) | `链接无法访问(inaccessible-link)` | ❌ 不关闭 | - ---- - -## 链接识别规则 - -| 类型 | 匹配模式 | 分类 | -| ------------ | ----------------------------------------------- | ---------------------- | -| GKD 分享链接 | `https://i.gkd.li/i/\d+` | `gkd` | -| GitHub 附件 | `https://github.com/user-attachments/files/...` | `github_attachment` | -| 不可访问快照 | `https://i.gkd.li/snapshot/...` | `unreachable_snapshot` | - ---- - -## 链接转换规则 - -### Bot 评论转换(GitHub 附件 → GKD 代理链接) - -- 仅转换 `github_attachment` 类型链接 -- 转换公式:`https://i.gkd.li/i?url={{原始GitHub附件URL}}` -- GKD 链接原样保留,不转换 - -### 网络检查转换(GKD 分享链接 → GH 附件 URL) - -- 仅用于网络可访问性检查,不影响 Bot 评论输出 -- 转换公式:`https://i.gkd.li/i/{id}` → `https://github.com/user-attachments/files/{id}/file.zip` -- `{id}` 为 GKD 链接中的数字部分,`file.zip` 为固定占位符 - ---- - -## Bot 评论格式 - -### 快照解析策略 - -- 下载 zip 到内存,解压读取 snapshot.json -- 同 Activity 只下载一个代表快照,其余只记录链接 -- GKD 分享链接先转 GH 附件 URL 再下载解析 -- 下载失败时仍作为可转换链接保留 - -### 主区域(直接可见) - -``` -## AppName `appId` versionName -device_model · Android release · GKD version - -**Activity** — 快查 ID:x Text:x · 深度x · 可点击x · xxx节点 -[snapshot_id](converted_url) - -**GKD 链接** -[display](url) · [display](url) -``` - -信息分层: -- App 标题:appName + appId + appVersionName -- App 副标题:device_model + Android版本 + GKD版本(同App只显示一次) -- Activity 行:activityId(取最后一段) + 快查ID/Text数 + 最大深度 + 可点击数 + 总节点数 -- 链接行:snapshot_id 链接到 GKD 代理 URL - -### 折叠区(详细信息) - -- 按 App 分组的详细信息表:可见节点 / 分辨率 / 方向 / appVersionCode / GKD版本号+构建号 / userId -- 设备信息表(去重):代号 / 型号 / 制造商 / 品牌 / SDK / Android - ---- - -## Python 模块结构 - -``` -scripts/python/ - ├── core/ # 核心功能层 - │ ├── extractor.py # 链接提取与分类 - │ ├── checker.py # 网络检查 - │ ├── converter.py # 链接转换 - │ └── snapshot_parser.py # 快照解析 - ├── utils/ # 工具模块层 - │ ├── models.py # 数据结构定义 - │ ├── common.py # 通用工具函数 - │ └── utils.py # GITHUB_OUTPUT 工具 - ├── api/ # 高层 API 层 - │ └── link_checker.py # 可复用的链接检查器 - ├── entry/ # 入口脚本层 - │ └── check_issue.py # Issue 场景主入口 - ├── tests/ # 测试层 - │ ├── verify.py # 本地验证脚本 - │ └── test_scenarios.json # 测试场景配置 - ├── formatter.py # 评论格式化(跨层使用) - └── README.md # 模块说明文档 -``` - -### 模块化要求 - -- 每个文件职责单一 -- 禁止互相重复代码 -- 禁止一个几百行的大脚本 -- 每个文件顶部说明用途 -- 每个函数必须有注释 -- 复杂逻辑必须有注释 - ---- - -## 网络检查策略 - -1. 优先 HEAD 请求(最快,只获取响应头) -2. HEAD 返回 405 时回退到 GET + Range 头(只请求前 1 字节) -3. 超时时间:20 秒 -4. 404 → 确认不可访问(非致命,不关闭 Issue) -5. 403 / 5xx → 不确定,折叠展示错误详情 -6. 3xx → 跟随重定向,以最终状态码为准 -7. GKD 分享链接先转换为 GH 附件 URL 再检查 - ---- - -## 使用的 GitHub Actions - -| Action | 用途 | -| ----------------------------------------- | --------------------------- | -| `actions/checkout@v7` | 拉取仓库代码 | -| `actions/setup-python@v6` | 初始化 Python 环境 | -| `peter-evans/find-comment@v4` | 查找已有评论(按作者+内容) | -| `peter-evans/create-or-update-comment@v5` | 发布/更新评论(防刷屏) | -| `gh` CLI(内置) | 标签操作、关闭/打开 Issue | - ---- - -## 代码风格 - -- Python 文件使用 UTF-8 编码 -- 类型注解(Python 3.10+ 语法) -- dataclass 用于数据结构 -- 不使用第三方库,仅使用 Python 标准库 -- YAML Step 名称使用中文 \ No newline at end of file From ea41f71c6d13dba62a2023d434431a5fe8de8c89 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 06:16:04 +0800 Subject: [PATCH 52/90] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E7=BC=93=E5=AD=98=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YAML workflow: 添加 cache restore/save 步骤 - check_issue.py: 添加缓存加载/保存/查询函数 - URL 完全匹配时直接命中缓存,跳过下载 - 仅 issues 事件有写权限,issue_comment 只读 --- .github/workflows/issue_content_check.yml | 17 ++++ scripts/python/entry/check_issue.py | 102 ++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 5ed2519dd..fe45366f0 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -109,6 +109,15 @@ jobs: echo "EOF" >> "$GITHUB_OUTPUT" fi + # 恢复快照缓存(所有事件都可读取) + - name: 恢复快照缓存 + uses: actions/cache/restore@v4 + with: + path: /tmp/snapshot_cache + key: snapshots-${{ github.event.issue.number }} + restore-keys: | + snapshots- + - name: 分析 Issue 快照链接 id: analyze env: @@ -139,6 +148,14 @@ jobs: python3 scripts/python/entry/check_issue.py fi + # 保存快照缓存(仅 issues 事件有写权限) + - name: 保存快照缓存 + if: github.event_name == 'issues' + uses: actions/cache/save@v4 + with: + path: /tmp/snapshot_cache + key: snapshots-${{ github.event.issue.number }} + # ── 线性步骤一:缺失快照 → 打标签 + 评论 + 关闭(致命,阻断后续所有 Job) ── handle-missing-snapshot: diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index 4ee67ad84..598e962cd 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -263,6 +263,8 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st """ 下载并解析所有快照链接,同 Activity 的所有快照都保留。 + 支持缓存:优先从缓存读取,命中则跳过下载。 + 返回: - snapshots:解析成功的 SnapshotInfo 列表(同 Activity 的所有快照都在) - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] @@ -272,12 +274,26 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st snapshots: list[SnapshotInfo] = [] gkd_links: list[tuple[str, str]] = [] + # 加载缓存 + cache = _load_cache() + cache_updated = False + # 先处理 GitHub 附件链接 for lnk in links: if lnk.kind != "github_attachment": continue converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + + # 尝试从缓存读取 + snap = _snapshot_from_cache(lnk.url, cache) + if snap: + # 缓存命中,使用缓存的 converted_url + snap.converted_url = converted_url + snapshots.append(snap) + continue + + # 缓存未命中,下载解析 snap = download_and_parse(lnk.url, converted_url) if snap is None: @@ -285,6 +301,10 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st gkd_links.append((lnk.display_text or _extract_filename(lnk.url), converted_url)) continue + # 保存到缓存 + _snapshot_to_cache(lnk.url, snap, cache) + cache_updated = True + # 所有成功解析的快照都添加到 snapshots 列表 snapshots.append(snap) @@ -297,15 +317,30 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st if not gh_url: continue + # 尝试从缓存读取(GKD 链接使用转换后的 URL 作为 key) + snap = _snapshot_from_cache(lnk.url, cache) + if snap: + snapshots.append(snap) + continue + + # 缓存未命中,下载解析 snap = download_and_parse(gh_url, lnk.url) if snap is None: gkd_links.append((lnk.display_text or lnk.url, lnk.url)) continue + # 保存到缓存 + _snapshot_to_cache(lnk.url, snap, cache) + cache_updated = True + # 所有成功解析的快照都添加到 snapshots 列表 snapshots.append(snap) + # 保存缓存(如果有更新) + if cache_updated: + _save_cache(cache) + return snapshots, gkd_links @@ -320,6 +355,73 @@ def _output(**kwargs): write_output(key, value) +# ── 缓存相关函数 ── + +_CACHE_DIR = "/tmp/snapshot_cache" +_CACHE_FILE = "snapshots.json" + + +def _load_cache() -> dict[str, dict]: + """ + 加载快照缓存。 + + 缓存结构:{url: SnapshotInfo_dict, ...} + """ + import json + import os + + cache_file = os.path.join(_CACHE_DIR, _CACHE_FILE) + if not os.path.exists(cache_file): + return {} + + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def _save_cache(cache: dict[str, dict]): + """ + 保存快照缓存。 + + 缓存结构:{url: SnapshotInfo_dict, ...} + """ + import json + import os + + os.makedirs(_CACHE_DIR, exist_ok=True) + cache_file = os.path.join(_CACHE_DIR, _CACHE_FILE) + + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(cache, f, ensure_ascii=False, indent=2) + + +def _snapshot_from_cache(url: str, cache: dict[str, dict]) -> SnapshotInfo | None: + """ + 从缓存中恢复 SnapshotInfo。 + + 如果 URL 在缓存中且数据有效,返回 SnapshotInfo;否则返回 None。 + """ + if url not in cache: + return None + + try: + data = cache[url] + return SnapshotInfo(**data) + except Exception: + return None + + +def _snapshot_to_cache(url: str, snap: SnapshotInfo, cache: dict[str, dict]): + """ + 将 SnapshotInfo 保存到缓存。 + """ + from dataclasses import asdict + + cache[url] = asdict(snap) + + def _merge_links_dedup(history_links: list[LinkInfo], new_links: list[LinkInfo]) -> list[LinkInfo]: """ 合并历史链接和新链接,基于 URL 去重。 From 05efa58fe237820c7c2d2c0d7a95702cc343c4a0 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 06:43:44 +0800 Subject: [PATCH 53/90] =?UTF-8?q?feat:=20=E4=B8=BA=20Python=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=B7=BB=E5=8A=A0=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_extractor.py: 13 个测试用例覆盖链接提取与分类 - test_converter.py: 6 个测试用例覆盖链接转换逻辑 - test_formatter.py: 20 个测试用例覆盖评论格式化 - run_tests.sh: 条件运行脚本,仅在 Python/YAML 变更时触发 - package.json: pre-push hook 中集成 Python 测试 --- package.json | 2 +- scripts/python/tests/run_tests.sh | 31 ++++ scripts/python/tests/test_converter.py | 92 ++++++++++ scripts/python/tests/test_extractor.py | 115 +++++++++++++ scripts/python/tests/test_formatter.py | 230 +++++++++++++++++++++++++ 5 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 scripts/python/tests/run_tests.sh create mode 100644 scripts/python/tests/test_converter.py create mode 100644 scripts/python/tests/test_extractor.py create mode 100644 scripts/python/tests/test_formatter.py diff --git a/package.json b/package.json index a5ce75678..a6376bbd2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "simple-git-hooks": { "pre-commit": "pnpm exec lint-staged", "commit-msg": "pnpm exec commitlint --config commitlint.config.ts --edit ${1}", - "pre-push": "pnpm run check" + "pre-push": "pnpm run check && bash scripts/python/tests/run_tests.sh" }, "lint-staged": { "*.{js,cjs,mjs,ts,jsx,tsx}": [ diff --git a/scripts/python/tests/run_tests.sh b/scripts/python/tests/run_tests.sh new file mode 100644 index 000000000..d63a01e03 --- /dev/null +++ b/scripts/python/tests/run_tests.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# 条件运行 Python 单元测试 +# +# 仅当 scripts/python/ 或 .github/workflows/ 下有文件变更时才运行测试。 +# 修改 src/apps/*.ts 等 TypeScript 文件时自动跳过。 +# +# 使用方法: +# bash scripts/python/tests/run_tests.sh + +# 对比 origin/main,获取本次 push 涉及的变更文件 +changed_files=$(git diff --name-only origin/main..HEAD 2>/dev/null) + +# 检查是否有 Python 或 YAML 文件变更 +need_test=false +for file in $changed_files; do + case "$file" in + scripts/python/*|.github/workflows/*) + need_test=true + break + ;; + esac +done + +if [ "$need_test" = false ]; then + echo "⏭️ 无 Python/YAML 文件变更,跳过 Python 测试" + exit 0 +fi + +echo "🔍 检测到 Python/YAML 文件变更,运行 Python 单元测试..." +cd scripts/python +PYTHONPATH=. python -m unittest discover -s tests -p "test_*.py" -v diff --git a/scripts/python/tests/test_converter.py b/scripts/python/tests/test_converter.py new file mode 100644 index 000000000..ce872acf1 --- /dev/null +++ b/scripts/python/tests/test_converter.py @@ -0,0 +1,92 @@ +""" +converter.py 单元测试 + +测试链接转换模块的 convert_github_attachments() 函数: +- GitHub 附件 URL → GKD 代理 URL 的转换逻辑 +- 文件名解析(App/Activity/timestamp) +""" + +import unittest +from core.converter import convert_github_attachments, GKD_PROXY_TEMPLATE +from utils.models import LinkInfo + + +class TestConvertGithubAttachments(unittest.TestCase): + """测试 convert_github_attachments() 函数""" + + def test_convert_github_attachment(self): + """GitHub 附件链接应正确转换为 GKD 代理链接""" + links = [ + LinkInfo( + url="https://github.com/user-attachments/files/12345/snapshot.zip", + kind="github_attachment", + display_text="", + ) + ] + result = convert_github_attachments(links) + self.assertEqual(len(result), 1) + expected_url = GKD_PROXY_TEMPLATE.format( + url="https://github.com/user-attachments/files/12345/snapshot.zip" + ) + self.assertEqual(result[0].converted_url, expected_url) + self.assertEqual(result[0].original_url, links[0].url) + + def test_skip_non_github(self): + """GKD 链接应被跳过,不返回结果""" + links = [ + LinkInfo(url="https://i.gkd.li/i/29899905", kind="gkd", display_text="") + ] + result = convert_github_attachments(links) + self.assertEqual(result, []) + + def test_filename_parse_valid(self): + """文件名符合 {App}_{Activity}-{timestamp}.zip 模式时应正确解析""" + links = [ + LinkInfo( + url="https://github.com/user-attachments/files/123/WeChat_com.tencent.mm-1712345678.zip", + kind="github_attachment", + display_text="", + ) + ] + result = convert_github_attachments(links) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].app_name, "WeChat") + self.assertEqual(result[0].activity_name, "com.tencent.mm") + self.assertEqual(result[0].timestamp, "1712345678") + + def test_filename_parse_invalid(self): + """文件名不符合模式时字段应为空字符串""" + links = [ + LinkInfo( + url="https://github.com/user-attachments/files/123/snapshot.zip", + kind="github_attachment", + display_text="", + ) + ] + result = convert_github_attachments(links) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].app_name, "") + self.assertEqual(result[0].activity_name, "") + self.assertEqual(result[0].timestamp, "") + + def test_empty_list(self): + """空列表应返回空结果""" + result = convert_github_attachments([]) + self.assertEqual(result, []) + + def test_preserve_display_text(self): + """Markdown 显示文字应被保留""" + links = [ + LinkInfo( + url="https://github.com/user-attachments/files/123/snap.zip", + kind="github_attachment", + display_text="快照1", + ) + ] + result = convert_github_attachments(links) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].display_text, "快照1") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/python/tests/test_extractor.py b/scripts/python/tests/test_extractor.py new file mode 100644 index 000000000..72b7e0a8f --- /dev/null +++ b/scripts/python/tests/test_extractor.py @@ -0,0 +1,115 @@ +""" +extractor.py 单元测试 + +测试链接提取与分类模块的两个核心函数: +- extract_links():从 Issue Body 提取快照链接 +- extract_links_from_bot_comment():从旧 Bot 评论提取链接 +""" + +import unittest +from core.extractor import extract_links, extract_links_from_bot_comment + + +class TestExtractLinks(unittest.TestCase): + """测试 extract_links() 函数""" + + def test_gkd_link(self): + """纯 GKD 分享链接应分类为 gkd""" + result = extract_links("https://i.gkd.li/i/29899905") + self.assertEqual(len(result), 1) + self.assertEqual(result[0].url, "https://i.gkd.li/i/29899905") + self.assertEqual(result[0].kind, "gkd") + self.assertEqual(result[0].display_text, "") + + def test_github_attachment(self): + """GitHub 附件链接应分类为 github_attachment""" + url = "https://github.com/user-attachments/files/12345/snapshot.zip" + result = extract_links(url) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].url, url) + self.assertEqual(result[0].kind, "github_attachment") + + def test_unreachable_snapshot(self): + """i.gkd.li/snapshot/ 链接应分类为 unreachable_snapshot""" + url = "https://i.gkd.li/snapshot/abc123" + result = extract_links(url) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].url, url) + self.assertEqual(result[0].kind, "unreachable_snapshot") + + def test_markdown_link_preserves_display_text(self): + """Markdown 格式链接应保留显示文字""" + result = extract_links("[开屏广告快照](https://i.gkd.li/i/29899905)") + self.assertEqual(len(result), 1) + self.assertEqual(result[0].url, "https://i.gkd.li/i/29899905") + self.assertEqual(result[0].kind, "gkd") + self.assertEqual(result[0].display_text, "开屏广告快照") + + def test_ignore_non_target_url(self): + """非目标 URL 不应返回任何结果""" + result = extract_links("https://google.com") + self.assertEqual(result, []) + + def test_dedup(self): + """同一 URL 出现两次应只返回一次""" + body = "https://i.gkd.li/i/29899905 和 https://i.gkd.li/i/29899905" + result = extract_links(body) + self.assertEqual(len(result), 1) + + def test_mixed_types(self): + """混合类型链接应各自返回正确的 kind""" + body = ( + "有效快照:https://i.gkd.li/i/29899905\n" + "GitHub附件:https://github.com/user-attachments/files/123/snap.zip\n" + "不可访问:https://i.gkd.li/snapshot/abc123" + ) + result = extract_links(body) + self.assertEqual(len(result), 3) + kinds = {r.kind for r in result} + self.assertEqual(kinds, {"gkd", "github_attachment", "unreachable_snapshot"}) + + def test_empty_string(self): + """空字符串应返回空列表""" + result = extract_links("") + self.assertEqual(result, []) + + def test_no_links(self): + """无链接文本应返回空列表""" + result = extract_links("这个应用有很多广告需要处理") + self.assertEqual(result, []) + + +class TestExtractLinksFromBotComment(unittest.TestCase): + """测试 extract_links_from_bot_comment() 函数""" + + def test_bot_snapshot_link(self): + """[snapshot_id](url) 格式应从 snapshot_id 构造 GKD 链接""" + comment = "[1783704841971](https://i.gkd.li/i/29899905)" + result = extract_links_from_bot_comment(comment) + self.assertEqual(len(result), 1) + # 函数从 snapshot_id 构造 GKD URL,不是用原始 URL + self.assertEqual(result[0].url, "https://i.gkd.li/i/1783704841971") + self.assertEqual(result[0].kind, "gkd") + self.assertEqual(result[0].display_text, "1783704841971") + + def test_bot_gkd_link(self): + """纯 GKD 链接应正确提取""" + comment = "https://i.gkd.li/i/29899905" + result = extract_links_from_bot_comment(comment) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].url, "https://i.gkd.li/i/29899905") + self.assertEqual(result[0].kind, "gkd") + + def test_empty_comment(self): + """空评论应返回空列表""" + result = extract_links_from_bot_comment("") + self.assertEqual(result, []) + + def test_none_comment(self): + """None 应返回空列表""" + result = extract_links_from_bot_comment(None) + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/python/tests/test_formatter.py b/scripts/python/tests/test_formatter.py new file mode 100644 index 000000000..43948b035 --- /dev/null +++ b/scripts/python/tests/test_formatter.py @@ -0,0 +1,230 @@ +""" +formatter.py 单元测试 + +测试评论格式化模块的所有函数: +- 警告评论生成(5 个函数) +- Bot 评论生成(build_bot_comment) +""" + +import unittest +from formatter import ( + build_warning_missing, + build_warning_unreachable, + build_warning_inaccessible, + build_warning_uncertain, + build_recovery_comment, + build_bot_comment, +) +from utils.models import SnapshotInfo + + +def _make_snapshot(**overrides) -> SnapshotInfo: + """ + 构造 SnapshotInfo 测试对象,未指定的字段使用默认值。 + + 参数: + overrides: 覆盖默认值的字段字典 + """ + defaults = { + "app_name": "测试应用", + "app_id": "com.test.app", + "app_version_name": "1.0.0", + "app_version_code": "1", + "activity_id": "com.test.app.MainActivity", + "snapshot_id": "1783704841971", + "screen_width": 1080, + "screen_height": 2400, + "is_landscape": False, + "gkd_version_name": "1.12.1", + "gkd_version_code": "92", + "gkd_user_id": "0", + "device_code": "test123", + "device_model": "TEST 10", + "device_manufacturer": "test", + "device_brand": "test", + "device_sdk": 34, + "device_release": "14", + "total_nodes": 20, + "visible_nodes": 15, + "clickable_nodes": 3, + "max_depth": 10, + "id_qf_count": 2, + "text_qf_count": 1, + "original_url": "https://github.com/user-attachments/files/123/snap.zip", + "converted_url": "https://i.gkd.li/i?url=https://github.com/user-attachments/files/123/snap.zip", + } + defaults.update(overrides) + return SnapshotInfo(**defaults) + + +class TestWarningMissing(unittest.TestCase): + """测试 build_warning_missing()""" + + def test_contains_html_marker(self): + """应包含 HTML 标记注释""" + result = build_warning_missing("testuser") + self.assertIn("", result) + + def test_contains_username(self): + """应包含 @用户名""" + result = build_warning_missing("testuser") + self.assertIn("@testuser", result) + + def test_contains_close_message(self): + """应包含自动关闭提示""" + result = build_warning_missing("testuser") + self.assertIn("自动关闭", result) + + +class TestWarningUnreachable(unittest.TestCase): + """测试 build_warning_unreachable()""" + + def test_contains_html_marker(self): + result = build_warning_unreachable("testuser") + self.assertIn("", result) + + def test_contains_snapshot_hint(self): + """应包含 snapshot 说明""" + result = build_warning_unreachable("testuser") + self.assertIn("snapshot", result) + + +class TestWarningInaccessible(unittest.TestCase): + """测试 build_warning_inaccessible()""" + + def test_contains_html_marker(self): + result = build_warning_inaccessible("testuser", "https://example.com") + self.assertIn("", result) + + def test_contains_url(self): + """应包含无法访问的 URL""" + url = "https://i.gkd.li/i/99999999" + result = build_warning_inaccessible("testuser", url) + self.assertIn(url, result) + + +class TestWarningUncertain(unittest.TestCase): + """测试 build_warning_uncertain()""" + + def test_contains_html_marker(self): + result = build_warning_uncertain("testuser", "https://example.com", 403, "Forbidden") + self.assertIn("", result) + + def test_contains_status_code(self): + """应包含 HTTP 状态码""" + result = build_warning_uncertain("testuser", "https://example.com", 403, "Forbidden") + self.assertIn("403", result) + + def test_contains_details_tag(self): + """应包含折叠详情标签""" + result = build_warning_uncertain("testuser", "https://example.com", 500, "Server Error") + self.assertIn("
", result) + self.assertIn("
", result) + + +class TestRecoveryComment(unittest.TestCase): + """测试 build_recovery_comment()""" + + def test_contains_html_marker(self): + result = build_recovery_comment("testuser") + self.assertIn("", result) + + def test_contains_success_emoji(self): + """应包含成功 emoji""" + result = build_recovery_comment("testuser") + self.assertIn("✅", result) + + +class TestBuildBotComment(unittest.TestCase): + """测试 build_bot_comment()""" + + def test_empty_input(self): + """空输入应返回空字符串""" + result = build_bot_comment([], []) + self.assertEqual(result, "") + + def test_single_app(self): + """单 App 单 Activity 应包含标题和 Activity 行""" + snap = _make_snapshot() + result = build_bot_comment([snap], []) + + # App 标题 + self.assertIn("## 测试应用 `com.test.app` 1.0.0", result) + # Activity 行 + self.assertIn("**MainActivity**", result) + # 统计信息 + self.assertIn("快查 ID:2", result) + self.assertIn("Text:1", result) + self.assertIn("深度10", result) + self.assertIn("可点击3", result) + self.assertIn("20节点", result) + + def test_multiple_activities(self): + """多 Activity 应各有独立行""" + snap1 = _make_snapshot(activity_id="com.test.app.Activity1") + snap2 = _make_snapshot(activity_id="com.test.app.Activity2") + result = build_bot_comment([snap1, snap2], []) + + self.assertIn("**Activity1**", result) + self.assertIn("**Activity2**", result) + + def test_gkd_links_section(self): + """有 GKD 链接时应包含 GKD 链接区域""" + gkd_links = [("1783704841971", "https://i.gkd.li/i/29899905")] + result = build_bot_comment([], gkd_links) + + self.assertIn("**GKD 链接**", result) + self.assertIn("1783704841971", result) + + def test_detail_section(self): + """应包含折叠详情区""" + snap = _make_snapshot() + result = build_bot_comment([snap], []) + + self.assertIn("
", result) + self.assertIn("
", result) + self.assertIn("**设备信息**", result) + + def test_device_dedup(self): + """同设备的多个快照应只显示一行设备信息""" + snap1 = _make_snapshot(snapshot_id="111") + snap2 = _make_snapshot(snapshot_id="222") + result = build_bot_comment([snap1, snap2], []) + + # 设备信息表中 "TEST 10" 应只出现一次 + device_count = result.count("| TEST 10 |") + self.assertEqual(device_count, 1) + + def test_device_subtitle(self): + """App 副标题应包含设备型号、Android 版本、GKD 版本""" + snap = _make_snapshot() + result = build_bot_comment([snap], []) + + self.assertIn("TEST 10", result) + self.assertIn("Android 14", result) + self.assertIn("GKD 1.12.1", result) + + def test_link_per_activity(self): + """每个 Activity 的链接应独立展示""" + snap1 = _make_snapshot( + activity_id="com.test.app.Activity1", + converted_url="https://i.gkd.li/i/111", + ) + snap2 = _make_snapshot( + activity_id="com.test.app.Activity2", + converted_url="https://i.gkd.li/i/222", + ) + result = build_bot_comment([snap1, snap2], []) + + # 每个 Activity 行后应有各自的链接 + lines = result.split("\n") + act1_line_idx = next(i for i, l in enumerate(lines) if "**Activity1**" in l) + act2_line_idx = next(i for i, l in enumerate(lines) if "**Activity2**" in l) + + # 链接应在各自 Activity 行之后 + self.assertIn("111", lines[act1_line_idx + 1]) + self.assertIn("222", lines[act2_line_idx + 1]) + + +if __name__ == "__main__": + unittest.main() From c4aa48c15078754324422a882eaeb9cda7cec99b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 07:53:49 +0800 Subject: [PATCH 54/90] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20ruff=20?= =?UTF-8?q?=E9=9D=99=E6=80=81=E6=A3=80=E6=9F=A5=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=A3=8E=E6=A0=BC=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/python/ruff.toml 配置文件,规则覆盖 E/W/F/I/B/UP - lint-staged 集成 ruff check + ruff format(*.py 文件) - run_tests.sh 增加 ruff check + format 检查步骤 - 自动修复:import 排序、未使用导入、缺少换行、冗余 f-string - 手动修复:check_issue.py 的 E402 加 noqa 标记、test_formatter.py 变量名 --- package.json | 4 +++ scripts/python/api/link_checker.py | 37 +++++++++++--------- scripts/python/core/checker.py | 9 ++--- scripts/python/core/converter.py | 21 +++++------ scripts/python/core/extractor.py | 7 ++-- scripts/python/core/snapshot_parser.py | 8 ++--- scripts/python/entry/check_issue.py | 32 ++++++++--------- scripts/python/formatter.py | 48 +++++++++++--------------- scripts/python/ruff.toml | 25 ++++++++++++++ scripts/python/tests/run_tests.sh | 13 ++++++- scripts/python/tests/test_converter.py | 11 +++--- scripts/python/tests/test_extractor.py | 1 + scripts/python/tests/test_formatter.py | 13 +++---- scripts/python/tests/verify.py | 24 +++++++------ scripts/python/utils/common.py | 1 - scripts/python/utils/models.py | 29 ++++++++-------- scripts/python/utils/utils.py | 3 +- 17 files changed, 154 insertions(+), 132 deletions(-) create mode 100644 scripts/python/ruff.toml diff --git a/package.json b/package.json index a6376bbd2..f9a99fa45 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,10 @@ ], "*.json": [ "prettier --cache --write" + ], + "*.py": [ + "ruff check --fix", + "ruff format" ] }, "publishConfig": { diff --git a/scripts/python/api/link_checker.py b/scripts/python/api/link_checker.py index 48a978492..9e1b0f388 100644 --- a/scripts/python/api/link_checker.py +++ b/scripts/python/api/link_checker.py @@ -22,18 +22,17 @@ print(f"{r.link.url}: {r.network_result.status}") """ +from core.checker import check_network_links, gkd_to_gh_attachment_url +from core.converter import GKD_PROXY_TEMPLATE +from core.extractor import extract_links +from core.snapshot_parser import download_and_parse from utils.models import ( + CheckReport, + LinkCheckResult, LinkInfo, NetworkResult, SnapshotInfo, - CheckReport, - LinkCheckResult, ) -from core.extractor import extract_links -from core.checker import check_network_links, gkd_to_gh_attachment_url -from core.converter import GKD_PROXY_TEMPLATE -from core.snapshot_parser import download_and_parse - # ── 快照相关链接类型集合 ── @@ -136,10 +135,12 @@ def extract_and_check(self, text: str) -> CheckReport: check_url = self._get_check_url(link) if not check_url: # 无法检查的链接类型,跳过网络检查 - results.append(LinkCheckResult( - link=link, - network_result=NetworkResult(status="skipped"), - )) + results.append( + LinkCheckResult( + link=link, + network_result=NetworkResult(status="skipped"), + ) + ) continue # 执行网络检查 @@ -150,12 +151,14 @@ def extract_and_check(self, text: str) -> CheckReport: if link.kind in ("github_attachment", "gkd"): snapshot = self._try_parse_snapshot(link, check_url) - results.append(LinkCheckResult( - link=link, - network_result=net_result, - converted_url=check_url, - snapshot=snapshot, - )) + results.append( + LinkCheckResult( + link=link, + network_result=net_result, + converted_url=check_url, + snapshot=snapshot, + ) + ) # 统计结果 ok_count = sum(1 for r in results if r.network_result.status == "ok") diff --git a/scripts/python/core/checker.py b/scripts/python/core/checker.py index 120196c5c..167661da7 100644 --- a/scripts/python/core/checker.py +++ b/scripts/python/core/checker.py @@ -14,7 +14,6 @@ from utils.models import LinkInfo, NetworkResult - # ── GKD 链接 → GH 附件 URL 转换 ── # 从 GKD 分享链接中提取数字 ID @@ -66,8 +65,6 @@ def check_network_links(url: str, timeout: int = 20) -> NetworkResult: - status="404":链接返回 404,确认不可访问 - status="uncertain":返回 403/5xx 等不确定状态码 """ - import urllib.request - import urllib.error result = _try_head_request(url, timeout) if result is not None: @@ -83,8 +80,8 @@ def _try_head_request(url: str, timeout: int) -> NetworkResult | None: 返回 None 表示服务器不支持 HEAD(如返回 405), 需要回退到 GET 请求。 """ - import urllib.request import urllib.error + import urllib.request try: req = urllib.request.Request(url, method="HEAD") @@ -127,8 +124,8 @@ def _try_get_range_request(url: str, timeout: int) -> NetworkResult: 用于兼容不支持 HEAD 方法的服务器。 """ - import urllib.request import urllib.error + import urllib.request try: req = urllib.request.Request(url, method="GET") @@ -168,4 +165,4 @@ def _try_get_range_request(url: str, timeout: int) -> NetworkResult: status="uncertain", status_code=0, detail=f"请求异常: {type(e).__name__}: {e}", - ) \ No newline at end of file + ) diff --git a/scripts/python/core/converter.py b/scripts/python/core/converter.py index fdf8df01a..46379d505 100644 --- a/scripts/python/core/converter.py +++ b/scripts/python/core/converter.py @@ -12,9 +12,8 @@ import re from dataclasses import dataclass -from utils.models import LinkInfo from utils.common import extract_github_filename - +from utils.models import LinkInfo # ── 数据结构 ── @@ -23,12 +22,12 @@ class ConvertedLink: """转换后的链接信息""" - original_url: str # 原始 GitHub 附件 URL - converted_url: str # 转换后的 GKD 代理 URL - display_text: str # 原始 Markdown 链接的显示文字 - app_name: str # 从文件名提取的 App 名称(不匹配时为空) - activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) - timestamp: str # 从文件名提取的时间戳(不匹配时为空) + original_url: str # 原始 GitHub 附件 URL + converted_url: str # 转换后的 GKD 代理 URL + display_text: str # 原始 Markdown 链接的显示文字 + app_name: str # 从文件名提取的 App 名称(不匹配时为空) + activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) + timestamp: str # 从文件名提取的时间戳(不匹配时为空) # ── 常量 ── @@ -37,9 +36,7 @@ class ConvertedLink: GKD_PROXY_TEMPLATE = "https://i.gkd.li/i?url={url}" # 文件名模式:{App}_{Activity}-{timestamp}.zip -_RE_NAME_PATTERN = re.compile( - r"^(?P.+?)_(?P.+?)-(?P\d+)\.zip$" -) +_RE_NAME_PATTERN = re.compile(r"^(?P.+?)_(?P.+?)-(?P\d+)\.zip$") # ── 转换函数 ── @@ -86,4 +83,4 @@ def convert_github_attachments(links: list[LinkInfo]) -> list[ConvertedLink]: ) ) - return results \ No newline at end of file + return results diff --git a/scripts/python/core/extractor.py b/scripts/python/core/extractor.py index 63973cd09..c1b795bf4 100644 --- a/scripts/python/core/extractor.py +++ b/scripts/python/core/extractor.py @@ -16,7 +16,6 @@ from utils.models import LinkInfo - # ── 正则模式 ── # Markdown 格式链接:[显示文字](URL) @@ -26,9 +25,7 @@ _RE_GKD_LINK = re.compile(r"https://i\.gkd\.li/i/\d+") # GitHub 附件链接:https://github.com/user-attachments/files/... -_RE_GITHUB_ATTACHMENT = re.compile( - r"https://github\.com/user-attachments/files/[^\s\)]+" -) +_RE_GITHUB_ATTACHMENT = re.compile(r"https://github\.com/user-attachments/files/[^\s\)]+") # 不可访问的快照链接:https://i.gkd.li/snapshot/... _RE_UNREACHABLE_SNAPSHOT = re.compile(r"https://i\.gkd\.li/snapshot/[^\s\)]*") @@ -142,4 +139,4 @@ def extract_links_from_bot_comment(comment: str) -> list[LinkInfo]: snapshot_id = url.split("/")[-1] results.append(LinkInfo(url=url, kind="gkd", display_text=snapshot_id)) - return results \ No newline at end of file + return results diff --git a/scripts/python/core/snapshot_parser.py b/scripts/python/core/snapshot_parser.py index 741f67bc7..705fdc568 100644 --- a/scripts/python/core/snapshot_parser.py +++ b/scripts/python/core/snapshot_parser.py @@ -13,14 +13,12 @@ import io import json -import zipfile - -import urllib.request import urllib.error +import urllib.request +import zipfile from utils.models import SnapshotInfo - # ── 下载与解析 ── @@ -166,4 +164,4 @@ def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> Snapsh text_qf_count=text_qf_count, original_url=original_url, converted_url=converted_url, - ) \ No newline at end of file + ) diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index 598e962cd..0a0d90f5b 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -30,34 +30,33 @@ import os import sys -from pathlib import Path from dataclasses import dataclass +from pathlib import Path # 自动设置模块搜索路径,确保能在任意目录下执行 _script_dir = Path(__file__).parent.parent # 指向 scripts/python 目录 if str(_script_dir) not in sys.path: sys.path.insert(0, str(_script_dir)) -from utils.models import LinkInfo, SnapshotInfo -from utils.common import extract_filename -from core.extractor import extract_links, extract_links_from_bot_comment -from core.checker import ( - check_unreachable_links, +from core.checker import ( # noqa: E402 check_network_links, + check_unreachable_links, gkd_to_gh_attachment_url, ) -from core.converter import convert_github_attachments, GKD_PROXY_TEMPLATE -from core.snapshot_parser import download_and_parse -from formatter import ( - build_warning_missing, - build_warning_unreachable, +from core.converter import GKD_PROXY_TEMPLATE # noqa: E402 +from core.extractor import extract_links, extract_links_from_bot_comment # noqa: E402 +from core.snapshot_parser import download_and_parse # noqa: E402 +from formatter import ( # noqa: E402 + build_bot_comment, + build_recovery_comment, build_warning_inaccessible, + build_warning_missing, build_warning_uncertain, - build_recovery_comment, - build_bot_comment, + build_warning_unreachable, ) -from utils.utils import write_output - +from utils.common import extract_filename # noqa: E402 +from utils.models import LinkInfo, SnapshotInfo # noqa: E402 +from utils.utils import write_output # noqa: E402 # ── 快照相关链接类型集合 ── @@ -269,7 +268,6 @@ def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[st - snapshots:解析成功的 SnapshotInfo 列表(同 Activity 的所有快照都在) - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] """ - from core.converter import GKD_PROXY_TEMPLATE snapshots: list[SnapshotInfo] = [] gkd_links: list[tuple[str, str]] = [] @@ -462,4 +460,4 @@ def _build_full_text_from_links(links: list[LinkInfo]) -> str: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index f0dfaf93d..58a6e5b3f 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -10,9 +10,8 @@ 本模块只负责内容生成,不负责评论发布(由 YAML 工作流完成)。 """ +from utils.common import short_activity_name from utils.models import SnapshotInfo -from utils.common import extract_filename, short_activity_name - # ── 警告评论生成 ── @@ -47,9 +46,7 @@ def build_warning_inaccessible(user: str, url: str) -> str: ) -def build_warning_uncertain( - user: str, url: str, status_code: int, detail: str -) -> str: +def build_warning_uncertain(user: str, url: str, status_code: int, detail: str) -> str: """链接返回不确定状态码时的提醒评论(不关闭,折叠错误详情)""" return ( "\n" @@ -62,10 +59,7 @@ def build_warning_uncertain( def build_recovery_comment(user: str) -> str: """编辑/评论补充有效链接后检查通过时的恢复评论""" - return ( - "\n" - f"✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" - ) + return f"\n✅ 您好 @{user},快照链接检查已通过,之前的标记已移除。" # ── Bot 转换评论生成 ── @@ -116,7 +110,9 @@ def build_bot_comment(snapshots: list[SnapshotInfo], gkd_links: list[tuple[str, # ── 分组与去重 ── -def _group_by_app(snapshots: list[SnapshotInfo]) -> tuple[dict[str, list[SnapshotInfo]], dict[str, list[tuple[str, str]]]]: +def _group_by_app( + snapshots: list[SnapshotInfo], +) -> tuple[dict[str, list[SnapshotInfo]], dict[str, list[tuple[str, str]]]]: """ 按 appId 分组,同 appId 下按 activityId 分组。 @@ -153,7 +149,9 @@ def _group_by_app(snapshots: list[SnapshotInfo]) -> tuple[dict[str, list[Snapsho # ── 主区域渲染 ── -def _render_app_section(lines: list[str], app_key: str, snapshots: list[SnapshotInfo], activity_links: dict[str, list[tuple[str, str]]]): +def _render_app_section( + lines: list[str], app_key: str, snapshots: list[SnapshotInfo], activity_links: dict[str, list[tuple[str, str]]] +): """ 渲染单个 App 的主区域内容。 @@ -248,12 +246,8 @@ def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: for app_key, app_snaps in app_groups.items(): lines.append(f"**{app_key}**") lines.append("") - lines.append( - "| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |" - ) - lines.append( - "|----------|------|--------|------|----------------|-----|--------|" - ) + lines.append("| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |") + lines.append("|----------|------|--------|------|----------------|-----|--------|") for snap in app_snaps: orientation = "横屏" if snap.is_landscape else "竖屏" resolution = f"{snap.screen_width}×{snap.screen_height}" @@ -296,18 +290,18 @@ def _deduplicate_devices(snapshots: list[SnapshotInfo]) -> list[dict]: if key in seen: continue seen.add(key) - result.append({ - "code": snap.device_code, - "model": snap.device_model, - "manufacturer": snap.device_manufacturer, - "brand": snap.device_brand, - "sdk": str(snap.device_sdk), - "release": snap.device_release, - }) + result.append( + { + "code": snap.device_code, + "model": snap.device_model, + "manufacturer": snap.device_manufacturer, + "brand": snap.device_brand, + "sdk": str(snap.device_sdk), + "release": snap.device_release, + } + ) return result # ── 工具函数 ── - - diff --git a/scripts/python/ruff.toml b/scripts/python/ruff.toml new file mode 100644 index 000000000..38f1aec47 --- /dev/null +++ b/scripts/python/ruff.toml @@ -0,0 +1,25 @@ +# Ruff 配置 +# 仅检查 scripts/python/ 下的 Python 脚本 + +target-version = "py310" +line-length = 120 + +[lint] +select = [ + "E", # pycodestyle 错误 + "W", # pycodestyle 警告 + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade +] +ignore = [ + "E501", # 行长度(prettier 不处理 Python,放宽限制) +] + +[lint.per-file-ignores] +"scripts/python/tests/*" = ["B011"] # 测试中允许 assert False + +[format] +quote-style = "double" +indent-style = "space" diff --git a/scripts/python/tests/run_tests.sh b/scripts/python/tests/run_tests.sh index d63a01e03..2b0244b74 100644 --- a/scripts/python/tests/run_tests.sh +++ b/scripts/python/tests/run_tests.sh @@ -26,6 +26,17 @@ if [ "$need_test" = false ]; then exit 0 fi -echo "🔍 检测到 Python/YAML 文件变更,运行 Python 单元测试..." +echo "🔍 检测到 Python/YAML 文件变更,运行 Python 静态检查 + 单元测试..." + +echo "" +echo "── ruff check ──" +ruff check scripts/python/ + +echo "" +echo "── ruff format check ──" +ruff format --check scripts/python/ + +echo "" +echo "── unittest ──" cd scripts/python PYTHONPATH=. python -m unittest discover -s tests -p "test_*.py" -v diff --git a/scripts/python/tests/test_converter.py b/scripts/python/tests/test_converter.py index ce872acf1..8a1769e99 100644 --- a/scripts/python/tests/test_converter.py +++ b/scripts/python/tests/test_converter.py @@ -7,7 +7,8 @@ """ import unittest -from core.converter import convert_github_attachments, GKD_PROXY_TEMPLATE + +from core.converter import GKD_PROXY_TEMPLATE, convert_github_attachments from utils.models import LinkInfo @@ -25,17 +26,13 @@ def test_convert_github_attachment(self): ] result = convert_github_attachments(links) self.assertEqual(len(result), 1) - expected_url = GKD_PROXY_TEMPLATE.format( - url="https://github.com/user-attachments/files/12345/snapshot.zip" - ) + expected_url = GKD_PROXY_TEMPLATE.format(url="https://github.com/user-attachments/files/12345/snapshot.zip") self.assertEqual(result[0].converted_url, expected_url) self.assertEqual(result[0].original_url, links[0].url) def test_skip_non_github(self): """GKD 链接应被跳过,不返回结果""" - links = [ - LinkInfo(url="https://i.gkd.li/i/29899905", kind="gkd", display_text="") - ] + links = [LinkInfo(url="https://i.gkd.li/i/29899905", kind="gkd", display_text="")] result = convert_github_attachments(links) self.assertEqual(result, []) diff --git a/scripts/python/tests/test_extractor.py b/scripts/python/tests/test_extractor.py index 72b7e0a8f..25c4834f8 100644 --- a/scripts/python/tests/test_extractor.py +++ b/scripts/python/tests/test_extractor.py @@ -7,6 +7,7 @@ """ import unittest + from core.extractor import extract_links, extract_links_from_bot_comment diff --git a/scripts/python/tests/test_formatter.py b/scripts/python/tests/test_formatter.py index 43948b035..076c89792 100644 --- a/scripts/python/tests/test_formatter.py +++ b/scripts/python/tests/test_formatter.py @@ -7,13 +7,14 @@ """ import unittest + from formatter import ( - build_warning_missing, - build_warning_unreachable, + build_bot_comment, + build_recovery_comment, build_warning_inaccessible, + build_warning_missing, build_warning_uncertain, - build_recovery_comment, - build_bot_comment, + build_warning_unreachable, ) from utils.models import SnapshotInfo @@ -218,8 +219,8 @@ def test_link_per_activity(self): # 每个 Activity 行后应有各自的链接 lines = result.split("\n") - act1_line_idx = next(i for i, l in enumerate(lines) if "**Activity1**" in l) - act2_line_idx = next(i for i, l in enumerate(lines) if "**Activity2**" in l) + act1_line_idx = next(i for i, line in enumerate(lines) if "**Activity1**" in line) + act2_line_idx = next(i for i, line in enumerate(lines) if "**Activity2**" in line) # 链接应在各自 Activity 行之后 self.assertIn("111", lines[act1_line_idx + 1]) diff --git a/scripts/python/tests/verify.py b/scripts/python/tests/verify.py index 8a9301e09..31028a18a 100644 --- a/scripts/python/tests/verify.py +++ b/scripts/python/tests/verify.py @@ -24,6 +24,7 @@ # 设置标准输出编码为 UTF-8(Windows 兼容) if sys.platform == "win32": import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") @@ -70,7 +71,7 @@ def load_scenarios() -> list[dict]: 返回: 场景列表 """ - with open(SCENARIOS_FILE, "r", encoding="utf-8") as f: + with open(SCENARIOS_FILE, encoding="utf-8") as f: data = json.load(f) return data.get("scenarios", []) @@ -91,6 +92,7 @@ def run_check_issue(env_vars: dict) -> dict[str, str]: # 创建临时 GITHUB_OUTPUT 文件 import tempfile + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: output_file = f.name @@ -119,7 +121,7 @@ def run_check_issue(env_vars: dict) -> dict[str, str]: # 解析 GITHUB_OUTPUT outputs = {} - with open(output_file, "r", encoding="utf-8") as f: + with open(output_file, encoding="utf-8") as f: content = f.read() # 解析 heredoc 格式:key< TestResult: description = scenario.get("description", "") result = TestResult(name, description) - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"测试场景: {name}") print(f"描述: {description}") - print(f"{'='*60}") + print(f"{'=' * 60}") # 运行脚本 print(" 运行 check_issue.py ...") @@ -221,10 +223,10 @@ def run_all_tests() -> list[TestResult]: scenarios = load_scenarios() results = [] - print(f"\n{'#'*60}") - print(f"# 本地验证: Python 脚本功能测试") + print(f"\n{'#' * 60}") + print("# 本地验证: Python 脚本功能测试") print(f"# 共 {len(scenarios)} 个测试场景") - print(f"{'#'*60}") + print(f"{'#' * 60}") for i, scenario in enumerate(scenarios, 1): print(f"\n[{i}/{len(scenarios)}]", end="") @@ -241,9 +243,9 @@ def print_summary(results: list[TestResult]): 参数: results: 所有测试结果 """ - print(f"\n{'#'*60}") - print(f"# 验证摘要") - print(f"{'#'*60}") + print(f"\n{'#' * 60}") + print("# 验证摘要") + print(f"{'#' * 60}") passed = sum(1 for r in results if r.passed) failed = sum(1 for r in results if not r.passed) @@ -253,7 +255,7 @@ def print_summary(results: list[TestResult]): print(f"失败: {failed} ❌") if failed > 0: - print(f"\n失败的场景:") + print("\n失败的场景:") for r in results: if not r.passed: print(f"\n ❌ {r.name}") diff --git a/scripts/python/utils/common.py b/scripts/python/utils/common.py index 3775ffe8d..ecd0e489f 100644 --- a/scripts/python/utils/common.py +++ b/scripts/python/utils/common.py @@ -7,7 +7,6 @@ import re - # ── URL 处理函数 ── diff --git a/scripts/python/utils/models.py b/scripts/python/utils/models.py index 5c0581e06..d5c79b784 100644 --- a/scripts/python/utils/models.py +++ b/scripts/python/utils/models.py @@ -7,7 +7,6 @@ from dataclasses import dataclass, field - # ── 链接相关数据结构 ── @@ -19,8 +18,8 @@ class LinkInfo: 由 extractor.py 的 extract_links() 函数返回。 """ - url: str # 完整 URL - kind: str # 分类:gkd / github_attachment / unreachable_snapshot + url: str # 完整 URL + kind: str # 分类:gkd / github_attachment / unreachable_snapshot display_text: str # Markdown 链接的显示文字,纯文本时为空 @@ -32,9 +31,9 @@ class NetworkResult: 由 checker.py 的 check_network_links() 函数返回。 """ - status: str # "ok" / "404" / "uncertain" / "skipped" - status_code: int = 0 # HTTP 状态码 - detail: str = "" # 错误详情(供折叠展示) + status: str # "ok" / "404" / "uncertain" / "skipped" + status_code: int = 0 # HTTP 状态码 + detail: str = "" # 错误详情(供折叠展示) @dataclass @@ -45,12 +44,12 @@ class ConvertedLink: 由 converter.py 的 convert_github_attachments() 函数返回。 """ - original_url: str # 原始 GitHub 附件 URL - converted_url: str # 转换后的 GKD 代理 URL - display_text: str # 原始 Markdown 链接的显示文字 - app_name: str # 从文件名提取的 App 名称(不匹配时为空) - activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) - timestamp: str # 从文件名提取的时间戳(不匹配时为空) + original_url: str # 原始 GitHub 附件 URL + converted_url: str # 转换后的 GKD 代理 URL + display_text: str # 原始 Markdown 链接的显示文字 + app_name: str # 从文件名提取的 App 名称(不匹配时为空) + activity_name: str # 从文件名提取的 Activity 名称(不匹配时为空) + timestamp: str # 从文件名提取的时间戳(不匹配时为空) # ── 快照相关数据结构 ── @@ -132,8 +131,8 @@ class CheckReport: 包含统计信息和详细的检查结果列表。 """ - total_links: int # 总链接数 - ok_count: int # 可访问链接数 - fail_count: int # 404 失败链接数 + total_links: int # 总链接数 + ok_count: int # 可访问链接数 + fail_count: int # 404 失败链接数 uncertain_count: int # 不确定链接数(403/5xx) links: list = field(default_factory=list) # list[LinkCheckResult] diff --git a/scripts/python/utils/utils.py b/scripts/python/utils/utils.py index 93e116c1e..af67b32c8 100644 --- a/scripts/python/utils/utils.py +++ b/scripts/python/utils/utils.py @@ -7,7 +7,6 @@ import os - # ── 本工作流管理的所有标签 ── MANAGED_LABELS = [ @@ -24,4 +23,4 @@ def write_output(key: str, value: str): 使用 heredoc 语法支持多行值,确保 Markdown 内容正确传递。 """ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: - f.write(f"{key}< Date: Mon, 13 Jul 2026 08:05:15 +0800 Subject: [PATCH 55/90] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Issue=20?= =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持三种输入方式:交互式、文件(--file)、管道 - 复用 check_issue.py 分析逻辑,跳过 GITHUB_OUTPUT - 默认跳过网络检查和快照下载,--with-network/--with-snapshot 可开启 - 终端直接输出链接提取、分析结果、警告评论、Bot 评论预览 --- scripts/python/debug_sim.py | 405 ++++++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 scripts/python/debug_sim.py diff --git a/scripts/python/debug_sim.py b/scripts/python/debug_sim.py new file mode 100644 index 000000000..7ab53d09f --- /dev/null +++ b/scripts/python/debug_sim.py @@ -0,0 +1,405 @@ +""" +Issue 模拟测试工具 + +本地交互式调试 check_issue.py 的分析逻辑,无需创建真实 GitHub Issue。 + +支持三种输入方式: + 1. 交互式:运行脚本后在终端输入 Issue Body,输入 END 结束 + 2. 文件:python debug_sim.py --file issue.md + 3. 管道:echo "..." | python debug_sim.py + +使用方法: + python scripts/python/debug_sim.py # 交互式 + python scripts/python/debug_sim.py --file issue.md # 文件 + python scripts/python/debug_sim.py --with-network # 启用网络检查 + python scripts/python/debug_sim.py --with-snapshot # 启用快照下载 + python scripts/python/debug_sim.py --user testuser # 指定用户名 + python scripts/python/debug_sim.py --action comment # 模拟评论事件 +""" + +import argparse +import sys +from pathlib import Path + +# 自动设置模块搜索路径 +_script_dir = Path(__file__).parent +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + +from core.checker import check_network_links, check_unreachable_links, gkd_to_gh_attachment_url # noqa: E402 +from core.converter import GKD_PROXY_TEMPLATE # noqa: E402 +from core.extractor import extract_links # noqa: E402 +from core.snapshot_parser import download_and_parse # noqa: E402 +from formatter import ( # noqa: E402 + build_bot_comment, + build_recovery_comment, + build_warning_inaccessible, + build_warning_missing, + build_warning_uncertain, + build_warning_unreachable, +) + +# ── 常量 ── + +_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} +_SEPARATOR = "─" * 40 + + +# ── 输入处理 ── + + +def read_interactive() -> str: + """交互式读取 Issue Body,支持 END 终止符。""" + print("Issue Body (输入 END 结束):") + lines = [] + while True: + try: + line = input("> ") + except EOFError: + break + if line.strip() == "END": + break + lines.append(line) + return "\n".join(lines) + + +def read_from_file(filepath: str) -> str: + """从文件读取 Issue Body。""" + return Path(filepath).read_text(encoding="utf-8") + + +def read_from_stdin() -> str: + """从标准输入读取(管道模式)。""" + return sys.stdin.read() + + +# ── 分析流程(复用 check_issue.py 逻辑) ── + + +def analyze( + body: str, + comment_body: str = "", + issue_user: str = "testuser", + issue_action: str = "opened", + with_network: bool = False, + with_snapshot: bool = False, +) -> dict: + """ + 执行 Issue 分析,返回所有结果。 + + 参数与 check_issue.py.main() 对应,但输出到字典而非 GITHUB_OUTPUT。 + """ + # 合并链接(简化版:不处理 history_content) + if issue_action == "comment" and comment_body: + links = extract_links(comment_body) + full_text = body + else: + full_text = body + links = extract_links(full_text) + + result = { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "skipped", + "network_detail": "", + "has_convertible": "false", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "", + } + + # ── 判断是否缺少快照 ── + has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) + + if not has_any_snapshot: + result["has_snapshot"] = "false" + result["warning_type"] = "missing" + result["comment_missing"] = build_warning_missing(issue_user) + return result + + # ── 检查不可访问快照链接 ── + unreachable_links = check_unreachable_links(links) + if unreachable_links: + result["has_unreachable"] = "true" + result["comment_unreachable"] = build_warning_unreachable(issue_user) + + # ── 网络有效性检查 ── + if with_network: + net_result = _check_all_links_interactive(links) + result["network_status"] = net_result["status"] + result["network_detail"] = net_result["detail"] + + if net_result["status"] == "404": + result["comment_404"] = build_warning_inaccessible(issue_user, net_result["fail_url"]) + + if net_result["status"] == "uncertain": + result["comment_uncertain"] = build_warning_uncertain( + issue_user, + net_result["uncertain_url"], + net_result["uncertain_code"], + net_result["uncertain_detail"], + ) + else: + result["network_status"] = "skipped" + + # ── 链接转换 + Bot 评论 ── + network_ok = result["network_status"] in ("ok", "skipped") + + if network_ok: + if with_snapshot: + snapshots, gkd_links = _parse_all_snapshots(links) + else: + snapshots, gkd_links = [], _build_gkd_links_preview(links) + + if snapshots or gkd_links: + result["has_convertible"] = "true" + comment_body_text = build_bot_comment(snapshots, gkd_links) + result["comment_bot"] = "\n" + comment_body_text + + # ── 恢复判断 ── + has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in links) + if issue_action in ("edited", "comment") and has_valid_snapshot and network_ok: + result["warning_type"] = "recovery" + result["comment_recovery"] = build_recovery_comment(issue_user) + + return result + + +def _check_all_links_interactive(links: list) -> dict: + """网络检查(交互式版本)。""" + result = { + "status": "skipped", + "detail": "", + "fail_url": "", + "uncertain_url": "", + "uncertain_code": 0, + "uncertain_detail": "", + } + + for lnk in links: + if lnk.kind == "github_attachment": + check_url = lnk.url + elif lnk.kind == "gkd": + check_url = gkd_to_gh_attachment_url(lnk.url) + if not check_url: + continue + else: + continue + + print(f" 检查: {check_url[:80]}...") + check = check_network_links(check_url) + + if check.status == "404": + result["status"] = "404" + result["fail_url"] = lnk.url + return result + + if check.status == "ok" and result["status"] == "skipped": + result["status"] = "ok" + + if check.status == "uncertain" and result["status"] != "uncertain": + result["status"] = "uncertain" + result["detail"] = f"HTTP {check.status_code}: {check.detail}" + result["uncertain_url"] = lnk.url + result["uncertain_code"] = check.status_code + result["uncertain_detail"] = check.detail + + return result + + +def _parse_all_snapshots(links: list) -> tuple[list, list[tuple[str, str]]]: + """下载并解析所有快照。""" + snapshots = [] + gkd_links = [] + + for lnk in links: + if lnk.kind == "github_attachment": + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + print(f" 下载快照: {lnk.url[:80]}...") + snap = download_and_parse(lnk.url, converted_url) + if snap: + snapshots.append(snap) + else: + gkd_links.append((lnk.display_text or lnk.url, converted_url)) + + elif lnk.kind == "gkd": + gh_url = gkd_to_gh_attachment_url(lnk.url) + if not gh_url: + continue + print(f" 下载快照: {lnk.url}") + snap = download_and_parse(gh_url, lnk.url) + if snap: + snapshots.append(snap) + else: + gkd_links.append((lnk.display_text or lnk.url, lnk.url)) + + return snapshots, gkd_links + + +def _build_gkd_links_preview(links: list) -> list[tuple[str, str]]: + """构建 GKD 链接预览(不下载快照时使用)。""" + gkd_links = [] + for lnk in links: + if lnk.kind == "github_attachment": + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + display = lnk.display_text or converted_url.split("/")[-1] + gkd_links.append((display, converted_url)) + elif lnk.kind == "gkd": + gkd_links.append((lnk.display_text or lnk.url, lnk.url)) + return gkd_links + + +# ── 输出格式化 ── + + +def print_links(links: list): + """打印提取的链接。""" + print(f"\n{_SEPARATOR}") + print("链接提取") + print(_SEPARATOR) + + if not links: + print(" (无链接)") + return + + for i, lnk in enumerate(links, 1): + display = f" [{lnk.display_text}]({lnk.url})" if lnk.display_text else f" {lnk.url}" + print(f" [{i}] kind={lnk.kind}") + print(f" {display}") + + +def print_result(result: dict): + """打印分析结果。""" + print(f"\n{_SEPARATOR}") + print("分析结果") + print(_SEPARATOR) + + flags = [ + ("has_snapshot", result["has_snapshot"]), + ("has_unreachable", result["has_unreachable"]), + ("network_status", result["network_status"]), + ("has_convertible", result["has_convertible"]), + ("warning_type", result["warning_type"] or "(empty)"), + ] + for key, value in flags: + print(f" {key:<20s} = {value}") + + +def print_warnings(result: dict): + """打印警告评论预览。""" + print(f"\n{_SEPARATOR}") + print("警告评论预览") + print(_SEPARATOR) + + warnings = [ + ("missing", result["comment_missing"]), + ("unreachable", result["comment_unreachable"]), + ("404", result["comment_404"]), + ("uncertain", result["comment_uncertain"]), + ("recovery", result["comment_recovery"]), + ] + + has_any = False + for label, comment in warnings: + if comment: + has_any = True + print(f"\n ── {label} ──") + for line in comment.split("\n"): + print(f" {line}") + + if not has_any: + print(" (无警告)") + + +def print_bot_comment(result: dict): + """打印 Bot 评论预览。""" + print(f"\n{_SEPARATOR}") + print("Bot 评论预览") + print(_SEPARATOR) + + comment = result["comment_bot"] + if not comment: + print(" (未生成 — 可能网络检查未通过或无可转换链接)") + return + + # 去掉 HTML 标记行 + lines = comment.split("\n") + content_lines = [line for line in lines if not line.startswith("\n" + comment_body_text + _ok(f"Bot 评论已生成 ({len(snapshots)} 快照, {len(gkd_links)} GKD 链接)") + else: + _info("无可转换链接,跳过 Bot 评论") + else: + _warn("网络检查未通过,跳过快照解析和 Bot 评论") # ── 恢复判断 ── has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in links) if issue_action in ("edited", "comment") and has_valid_snapshot and network_ok: result["warning_type"] = "recovery" result["comment_recovery"] = build_recovery_comment(issue_user) + _ok("触发恢复流程 (recovery)") return result -def _check_all_links_interactive(links: list) -> dict: - """网络检查(交互式版本)。""" +def _check_all_links_pipeline(links: list) -> dict: + """网络检查(流水线版本)。""" result = { "status": "skipped", "detail": "", @@ -180,34 +264,48 @@ def _check_all_links_interactive(links: list) -> dict: "uncertain_detail": "", } - for lnk in links: + checkable = [lnk for lnk in links if lnk.kind in ("github_attachment", "gkd")] + if not checkable: + _info("无可检查链接") + return result + + for lnk in checkable: if lnk.kind == "github_attachment": check_url = lnk.url - elif lnk.kind == "gkd": + else: check_url = gkd_to_gh_attachment_url(lnk.url) if not check_url: continue - else: - continue - print(f" 检查: {check_url[:80]}...") - check = check_network_links(check_url) + _info(f"检查 {check_url[:72]}...") + try: + check = check_network_links(check_url) + except Exception as e: + _warn(f"请求异常: {e}") + continue if check.status == "404": + _fail(f"404 Not Found → {lnk.url}") result["status"] = "404" result["fail_url"] = lnk.url return result - if check.status == "ok" and result["status"] == "skipped": - result["status"] = "ok" + if check.status == "ok": + _ok(f"200 OK → {lnk.url}") + if result["status"] == "skipped": + result["status"] = "ok" if check.status == "uncertain" and result["status"] != "uncertain": + _warn(f"HTTP {check.status_code} → {lnk.url}") result["status"] = "uncertain" result["detail"] = f"HTTP {check.status_code}: {check.detail}" result["uncertain_url"] = lnk.url result["uncertain_code"] = check.status_code result["uncertain_detail"] = check.detail + if result["status"] == "skipped": + _ok("所有链接检查完成") + return result @@ -219,22 +317,36 @@ def _parse_all_snapshots(links: list) -> tuple[list, list[tuple[str, str]]]: for lnk in links: if lnk.kind == "github_attachment": converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - print(f" 下载快照: {lnk.url[:80]}...") - snap = download_and_parse(lnk.url, converted_url) + _info(f"下载快照 {lnk.url[:72]}...") + try: + snap = download_and_parse(lnk.url, converted_url) + except Exception as e: + _warn(f"下载失败: {e}") + snap = None + if snap: + _ok(f"解析成功: {snap.app_id} / {snap.activity_id}") snapshots.append(snap) else: + _warn("解析失败,保留为 GKD 链接") gkd_links.append((lnk.display_text or lnk.url, converted_url)) elif lnk.kind == "gkd": gh_url = gkd_to_gh_attachment_url(lnk.url) if not gh_url: continue - print(f" 下载快照: {lnk.url}") - snap = download_and_parse(gh_url, lnk.url) + _info(f"下载快照 {lnk.url}...") + try: + snap = download_and_parse(gh_url, lnk.url) + except Exception as e: + _warn(f"下载失败: {e}") + snap = None + if snap: + _ok(f"解析成功: {snap.app_id} / {snap.activity_id}") snapshots.append(snap) else: + _warn("解析失败,保留为 GKD 链接") gkd_links.append((lnk.display_text or lnk.url, lnk.url)) return snapshots, gkd_links @@ -253,30 +365,14 @@ def _build_gkd_links_preview(links: list) -> list[tuple[str, str]]: return gkd_links -# ── 输出格式化 ── - - -def print_links(links: list): - """打印提取的链接。""" - print(f"\n{_SEPARATOR}") - print("链接提取") - print(_SEPARATOR) - - if not links: - print(" (无链接)") - return - - for i, lnk in enumerate(links, 1): - display = f" [{lnk.display_text}]({lnk.url})" if lnk.display_text else f" {lnk.url}" - print(f" [{i}] kind={lnk.kind}") - print(f" {display}") +# ── 结果汇总输出 ── -def print_result(result: dict): - """打印分析结果。""" - print(f"\n{_SEPARATOR}") - print("分析结果") - print(_SEPARATOR) +def print_summary(result: dict): + """打印结果汇总。""" + print(f"\n{'═' * _WIDTH}") + print("结果汇总") + print(f"{'═' * _WIDTH}") flags = [ ("has_snapshot", result["has_snapshot"]), @@ -288,13 +384,7 @@ def print_result(result: dict): for key, value in flags: print(f" {key:<20s} = {value}") - -def print_warnings(result: dict): - """打印警告评论预览。""" - print(f"\n{_SEPARATOR}") - print("警告评论预览") - print(_SEPARATOR) - + # 警告评论 warnings = [ ("missing", result["comment_missing"]), ("unreachable", result["comment_unreachable"]), @@ -302,36 +392,26 @@ def print_warnings(result: dict): ("uncertain", result["comment_uncertain"]), ("recovery", result["comment_recovery"]), ] - - has_any = False - for label, comment in warnings: - if comment: - has_any = True - print(f"\n ── {label} ──") - for line in comment.split("\n"): - print(f" {line}") - - if not has_any: - print(" (无警告)") - - -def print_bot_comment(result: dict): - """打印 Bot 评论预览。""" - print(f"\n{_SEPARATOR}") - print("Bot 评论预览") - print(_SEPARATOR) - + has_warnings = any(c for _, c in warnings) + if has_warnings: + print(f"\n{'─' * _WIDTH}") + print("警告评论") + print(f"{'─' * _WIDTH}") + for label, comment in warnings: + if comment: + print(f"\n ── {label} ──") + for line in comment.split("\n"): + print(f" {line}") + + # Bot 评论 comment = result["comment_bot"] - if not comment: - print(" (未生成 — 可能网络检查未通过或无可转换链接)") - return - - # 去掉 HTML 标记行 - lines = comment.split("\n") - content_lines = [line for line in lines if not line.startswith("\n" + comment_body + else: + # 全部都是坏链接 + network_status = net_result.status if net_result.status != "skipped" else "404" # ── 第六步:编辑/评论恢复判断 ── - # 当 edited 或 issue_comment 触发且所有检查均通过时,触发恢复流程 - # 恢复条件:edited/comment + 至少有一个有效快照链接 + 网络OK - # 不要求旧问题链接消失——作者补充有效链接即可恢复 - has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in links) + # 当 edited 或 issue_comment 触发且有好链接时,触发恢复流程 + has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in net_result.good_links) - if issue_action in ("edited", "comment") and has_valid_snapshot and network_status in ("ok", "skipped"): + if issue_action in ("edited", "comment") and has_valid_snapshot: warning_type = "recovery" comment_recovery = build_recovery_comment(issue_user) @@ -223,10 +226,11 @@ def _check_all_links(links: list) -> _NetworkCheckResult: - GitHub 附件链接:直接检查原始 URL - GKD 分享链接:先转换为 GH 附件 URL 再检查 - 遵循 Fail Fast 原则:遇到 404 立即返回。 - 不确定结果(403/5xx)为非致命,记录但不中断。 + 新逻辑:检查所有链接,区分好链接和坏链接。 + - 好链接:可以用于后续快照解析和转换 + - 坏链接:记录但不阻断好链接的处理 - 返回:_NetworkCheckResult 聚合结果 + 返回:_NetworkCheckResult 包含 good_links 和 bad_links """ result = _NetworkCheckResult() @@ -242,22 +246,30 @@ def _check_all_links(links: list) -> _NetworkCheckResult: check = check_network_links(check_url) - if check.status == "404": - result.status = "404" - result.fail_url = lnk.url - return result - if check.status == "ok": - # 检查成功,更新状态为 ok(只有首次成功时更新) + result.good_links.append(lnk) if result.status == "skipped": result.status = "ok" + elif check.status == "404": + result.bad_links.append(lnk) + result.fail_urls.append(lnk.url) + if result.status == "skipped": + result.status = "404" + elif check.status == "uncertain": + result.bad_links.append(lnk) + result.uncertain_urls.append(lnk.url) + if not result.uncertain_code: + result.uncertain_code = check.status_code + result.uncertain_detail = check.detail + result.detail = f"HTTP {check.status_code}: {check.detail}" + if result.status == "skipped": + result.status = "uncertain" - if check.status == "uncertain" and result.status != "uncertain": - result.status = "uncertain" - result.detail = f"HTTP {check.status_code}: {check.detail}" - result.uncertain_url = lnk.url - result.uncertain_code = check.status_code - result.uncertain_detail = check.detail + # 最终状态判断:有好链接就是 ok,全部坏链接才保持 404/uncertain + if result.good_links: + result.status = "ok" + elif result.bad_links and result.status == "skipped": + result.status = "404" return result diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index 58a6e5b3f..87a1e374c 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -36,23 +36,25 @@ def build_warning_unreachable(user: str) -> str: ) -def build_warning_inaccessible(user: str, url: str) -> str: +def build_warning_inaccessible(user: str, urls: list[str]) -> str: """链接不可访问(404)时的警告评论(不关闭 Issue)""" + url_list = "\n".join(f"`{url}`" for url in urls) return ( "\n" f"您好 @{user},检测到您提供的快照链接无法访问:\n\n" - f"`{url}`\n\n" + f"{url_list}\n\n" "请确认链接正确后在评论区补充有效的快照链接。" ) -def build_warning_uncertain(user: str, url: str, status_code: int, detail: str) -> str: +def build_warning_uncertain(user: str, urls: list[str], status_code: int, detail: str) -> str: """链接返回不确定状态码时的提醒评论(不关闭,折叠错误详情)""" + url_list = "\n".join(f"`{url}`" for url in urls) return ( "\n" f"您好 @{user},检测到快照链接访问异常(HTTP {status_code})," "暂时无法确认链接是否有效,请人工核查:\n\n" - f"`{url}`\n\n" + f"{url_list}\n\n" f"
\n详细错误信息\n\n```\n{detail}\n```\n
" ) diff --git a/scripts/python/tests/test_formatter.py b/scripts/python/tests/test_formatter.py index 076c89792..b38ec715b 100644 --- a/scripts/python/tests/test_formatter.py +++ b/scripts/python/tests/test_formatter.py @@ -94,31 +94,38 @@ class TestWarningInaccessible(unittest.TestCase): """测试 build_warning_inaccessible()""" def test_contains_html_marker(self): - result = build_warning_inaccessible("testuser", "https://example.com") + result = build_warning_inaccessible("testuser", ["https://example.com"]) self.assertIn("", result) def test_contains_url(self): """应包含无法访问的 URL""" url = "https://i.gkd.li/i/99999999" - result = build_warning_inaccessible("testuser", url) + result = build_warning_inaccessible("testuser", [url]) self.assertIn(url, result) + def test_multiple_urls(self): + """应包含多个无法访问的 URL""" + urls = ["https://i.gkd.li/i/11111111", "https://i.gkd.li/i/22222222"] + result = build_warning_inaccessible("testuser", urls) + self.assertIn(urls[0], result) + self.assertIn(urls[1], result) + class TestWarningUncertain(unittest.TestCase): """测试 build_warning_uncertain()""" def test_contains_html_marker(self): - result = build_warning_uncertain("testuser", "https://example.com", 403, "Forbidden") + result = build_warning_uncertain("testuser", ["https://example.com"], 403, "Forbidden") self.assertIn("", result) def test_contains_status_code(self): """应包含 HTTP 状态码""" - result = build_warning_uncertain("testuser", "https://example.com", 403, "Forbidden") + result = build_warning_uncertain("testuser", ["https://example.com"], 403, "Forbidden") self.assertIn("403", result) def test_contains_details_tag(self): """应包含折叠详情标签""" - result = build_warning_uncertain("testuser", "https://example.com", 500, "Server Error") + result = build_warning_uncertain("testuser", ["https://example.com"], 500, "Server Error") self.assertIn("
", result) self.assertIn("
", result) From fe40e9b27cbbf6e70a29ab3a90f9dee9daba155b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 14:54:51 +0800 Subject: [PATCH 67/90] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=96=87=E6=A1=A3=EF=BC=8C=E5=8F=8D=E6=98=A0=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E6=A3=80=E6=9F=A5=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 ci-cd.md:将 Fail Fast 改为「检查全部,跳过坏链接」 - 更新 debug_sim.py 文档:同步网络检查逻辑说明 --- .claude/rules/ci-cd.md | 10 +++++++--- scripts/python/debug_sim.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.claude/rules/ci-cd.md b/.claude/rules/ci-cd.md index f6612ac4c..30e4eb6ff 100644 --- a/.claude/rules/ci-cd.md +++ b/.claude/rules/ci-cd.md @@ -99,11 +99,14 @@ Python 脚本只在 `analyze` Job 中执行一次,输出所有原子化布尔 **原因:** 减少 setup 开销,避免重复解析 Issue Body。 -### 2. Fail Fast 原则 +### 2. 网络检查策略:检查全部,跳过坏链接 -网络检查遇到第一个 404 立即停止,不发后续请求。 +网络检查会检查所有链接,区分好链接和坏链接。 +- 好链接:用于后续快照解析和 Bot 评论生成 +- 坏链接:记录并生成警告评论,但不阻断好链接的处理 -**原因:** 节省网络请求和运行时间,审核类工作流不需要完整报告。 +**原因:** 用户可能提供多个快照链接,其中部分有效部分失效。 +只处理坏链接会浪费用户已提供的有效快照。 ### 3. 幂等性(Idempotent) @@ -278,6 +281,7 @@ device_model · Android release · GKD version 5. 403 / 5xx → 不确定,折叠展示错误详情 6. 3xx → 跟随重定向,以最终状态码为准 7. GKD 分享链接先转换为 GH 附件 URL 再检查 +8. 检查所有链接,区分好链接和坏链接,只处理好链接 --- diff --git a/scripts/python/debug_sim.py b/scripts/python/debug_sim.py index afb3c2740..52cd4c694 100644 --- a/scripts/python/debug_sim.py +++ b/scripts/python/debug_sim.py @@ -3,7 +3,7 @@ 本地交互式调试 check_issue.py 的分析逻辑,无需创建真实 GitHub Issue。 默认启用网络检查(模拟真实 CI 环境),网络不可用时自动降级为离线模式。 -与 check_issue.py 保持逻辑一致:历史链接合并、快照缓存、Fail Fast 网络检查。 +与 check_issue.py 保持逻辑一致:历史链接合并、快照缓存、网络检查(检查全部,跳过坏链接)。 支持三种输入方式: 1. 交互式:运行脚本后在终端输入 Issue Body,输入 END 结束 @@ -204,7 +204,7 @@ def analyze( 与 check_issue.py.main() 逻辑完全一致: - comment 事件合并历史链接 + 新链接 - 支持 old_bot / all 两种历史来源 - - 网络检查 Fail Fast + - 网络检查:检查全部链接,跳过坏链接 - 快照缓存 """ # ── 链接提取(与 check_issue.py 一致) ── From 9cdbfaa1c2d8e17dc993ef55f29e7fd8e1cc274d Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 15:04:52 +0800 Subject: [PATCH 68/90] =?UTF-8?q?chore(ci):=20=E6=89=8B=E8=AF=AF=E6=89=93?= =?UTF-8?q?=E9=94=99=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index c76d5ceee..e01347207 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -210,9 +210,9 @@ jobs: steps: - name: 打标签「需补充链接」 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}. ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue edit "$ISSUE_NUMBER" --add-label "缺失快照(no-snapshot)" + run: gh issue edit "$ISSUE_NUMBER" --add-label "需补充链接(needs-link)" - name: 查找已有警告评论 id: find-warning @@ -245,7 +245,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue edit "$ISSUE_NUMBER" --add-label "需补充链接(needs-link)" + run: gh issue edit "$ISSUE_NUMBER" --add-label "链接失效(broken-link)" - name: 查找已有警告评论 id: find-warning @@ -278,7 +278,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} - run: gh issue edit "$ISSUE_NUMBER" --add-label "需补充链接(needs-link)" + run: gh issue edit "$ISSUE_NUMBER" --add-label "链接失效(broken-link)" - name: 查找已有警告评论 id: find-warning From 2f320be22a5df97955402ae2885ab95e46ba10e0 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 15:20:16 +0800 Subject: [PATCH 69/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20network?= =?UTF-8?q?=5Fstatus=20=E9=80=BB=E8=BE=91=EF=BC=8C=E6=9C=89=E5=9D=8F?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=97=B6=E6=AD=A3=E7=A1=AE=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 之前:有好链接就设为 ok,忽略坏链接 - 现在:有坏链接就设为 404/uncertain,即使也有好链接 - 确保 404 警告评论正确生成 --- scripts/python/debug_sim.py | 8 ++++++-- scripts/python/entry/check_issue.py | 11 ++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/python/debug_sim.py b/scripts/python/debug_sim.py index 52cd4c694..8637cbdfe 100644 --- a/scripts/python/debug_sim.py +++ b/scripts/python/debug_sim.py @@ -290,8 +290,12 @@ def analyze( net_result["uncertain_detail"], ) - # 有好链接就是 ok - if net_result["good_links"]: + # network_status 反映最差状态:有坏链接就是对应状态 + if net_result["fail_urls"]: + result["network_status"] = "404" + elif net_result["uncertain_urls"]: + result["network_status"] = "uncertain" + elif net_result["good_links"]: result["network_status"] = "ok" else: result["network_status"] = net_result["status"] if net_result["status"] != "skipped" else "404" diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index 599f1cbc2..f97b19265 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -182,16 +182,21 @@ def main(): # ── 第五步:链接转换 + 快照解析 + Bot 评论生成 ── # 只处理好链接,跳过坏链接 - # 只要有好链接就允许转换 if net_result.good_links: - network_status = "ok" snapshots, gkd_links = _parse_all_snapshots(net_result.good_links) if snapshots or gkd_links: has_convertible = "true" comment_body = build_bot_comment(snapshots, gkd_links) comment_bot = "\n" + comment_body + + # network_status 反映最差状态:有坏链接就是对应状态 + if net_result.fail_urls: + network_status = "404" + elif net_result.uncertain_urls: + network_status = "uncertain" + elif net_result.good_links: + network_status = "ok" else: - # 全部都是坏链接 network_status = net_result.status if net_result.status != "skipped" else "404" # ── 第六步:编辑/评论恢复判断 ── From 2f05f5e8c954af207d79836e5220d464d9c3a882 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 15:23:20 +0800 Subject: [PATCH 70/90] =?UTF-8?q?fix:=20=E5=A4=9A=E6=89=93=E4=BA=86?= =?UTF-8?q?=E4=B8=AA=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index e01347207..2033fcc1d 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -210,7 +210,7 @@ jobs: steps: - name: 打标签「需补充链接」 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue edit "$ISSUE_NUMBER" --add-label "需补充链接(needs-link)" From a3b32e2345e1271dce5e28a94094931c7ac063da Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 15:46:08 +0800 Subject: [PATCH 71/90] =?UTF-8?q?fix:=20404&&=E6=9C=AC=E5=9C=B0->skip=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 2033fcc1d..c6774fc51 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -296,7 +296,7 @@ jobs: body: ${{ needs.analyze.outputs.comment_uncertain }} edit-mode: replace - # ── 步骤四:链接转换 + Bot 评论(仅当缺失未触发 + 网络可访问时) ── + # ── 步骤四:链接转换 + Bot 评论(仅当缺失未触发 + 有可转换链接时) ── handle-convert: name: 处理链接转换 @@ -304,14 +304,10 @@ jobs: [ analyze, handle-missing-snapshot, - handle-network-404, - handle-network-uncertain, ] if: >- always() && needs.handle-missing-snapshot.result == 'skipped' && - needs.handle-network-404.result == 'skipped' && - needs.handle-network-uncertain.result == 'skipped' && needs.analyze.outputs.has_convertible == 'true' runs-on: ubuntu-latest steps: From f0bbbbe149669640c9e6e78c00c39410d4f368ca Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 16:09:41 +0800 Subject: [PATCH 72/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20Bot=20?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E8=A6=86=E7=9B=96=E9=97=AE=E9=A2=98=E5=92=8C?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E8=BD=AC=E6=8D=A2=E9=98=BB=E6=96=AD=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 handle-convert 对 handle-network-404/uncertain 的依赖,有好链接时正常转换 - 移除 find-bot 步骤和 comment-id 参数,改为创建新评论而非覆盖旧评论 - 旧 Bot 评论被折叠为 OUTDATED,新评论独立存在 --- .github/workflows/issue_content_check.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index c6774fc51..16963ed7f 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -152,7 +152,7 @@ jobs: python3 scripts/python/entry/check_issue.py fi - # 保存快照缓存(仅 issues 事件有写权限) + # 保存快照缓存(仅 issues 事件有写权限,issue_comment 事件只读) - name: 保存快照缓存 if: github.event_name == 'issues' uses: actions/cache/save@v6 @@ -353,21 +353,11 @@ jobs: } }' -f subjectId="$NODE_ID" -f classifier="OUTDATED" - - name: 查找已有 Bot 评论 - id: find-bot - uses: peter-evans/find-comment@v4 - with: - issue-number: ${{ github.event.issue.number }} - comment-author: 'github-actions[bot]' - body-includes: '' - - - name: 发布/更新快照转换评论 + - name: 发布快照转换评论 uses: peter-evans/create-or-update-comment@v5 with: - comment-id: ${{ steps.find-bot.outputs.comment-id }} issue-number: ${{ github.event.issue.number }} body: ${{ needs.analyze.outputs.comment_bot }} - edit-mode: replace # ── 步骤五:编辑/评论恢复(仅当缺失未触发 + 所有检查通过时) ── From 285cd81b985dfdc4e43d6edf75ca9e7a6bcdfd3b Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 16:34:53 +0800 Subject: [PATCH 73/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=E4=BB=8E?= =?UTF-8?q?=E6=97=A7=20Bot=20=E8=AF=84=E8=AE=BA=E6=8F=90=E5=8F=96=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E6=97=B6=20URL=20=E9=94=99=E8=AF=AF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract_links_from_bot_comment 现在使用原始 URL 而不是构建新 URL - 确保从旧 Bot 评论提取的链接能正确用于快照解析 --- scripts/python/core/extractor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/python/core/extractor.py b/scripts/python/core/extractor.py index c1b795bf4..c7b7fa65d 100644 --- a/scripts/python/core/extractor.py +++ b/scripts/python/core/extractor.py @@ -126,9 +126,8 @@ def extract_links_from_bot_comment(comment: str) -> list[LinkInfo]: url = match.group(2) if url not in seen: seen.add(url) - # 将代理链接转换为 GKD 分享链接格式 - gkd_url = f"https://i.gkd.li/i/{snapshot_id}" - results.append(LinkInfo(url=gkd_url, kind="gkd", display_text=snapshot_id)) + # 使用原始 URL(已经是 GKD 代理链接格式) + results.append(LinkInfo(url=url, kind="gkd", display_text=snapshot_id)) # 提取纯文本 GKD 链接 for match in _RE_BOT_GKD_LINK.finditer(comment): From f38dc489d28bb78c407b8ea1099eb91682b5fb4e Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 16:47:31 +0800 Subject: [PATCH 74/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20get-histo?= =?UTF-8?q?ry=20=E6=AD=A5=E9=AA=A4=E6=97=A0=E6=B3=95=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=97=A7=20Bot=20=E8=AF=84=E8=AE=BA=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 jq 的 first // empty 语法获取第一个匹配的 Bot 评论 - 移除 head -1 避免截断多行 body 内容 - 确保历史链接能正确传递给 Python 代码进行合并 --- .github/workflows/issue_content_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 16963ed7f..12e582ba3 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -93,7 +93,7 @@ jobs: run: | # 1. 尝试读取旧 Bot 评论(已解析成功的快照链接) old_bot=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ - --jq '.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body' 2>/dev/null | head -1) + --jq -r '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body] | first // empty' 2>/dev/null) # 使用随机分隔符避免内容冲突 separator=$(head -c 16 /dev/urandom | base64) From 57c50fbc40386c146dd85a1b254a9c0e15c9a1ec Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Mon, 13 Jul 2026 16:50:58 +0800 Subject: [PATCH 75/90] =?UTF-8?q?fix(ci):=20=E6=B7=BB=E5=8A=A0=20get-histo?= =?UTF-8?q?ry=20=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97=E6=8E=92=E6=9F=A5?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E9=93=BE=E6=8E=A5=E5=90=88=E5=B9=B6=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 12e582ba3..d81391ac5 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -95,6 +95,8 @@ jobs: old_bot=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ --jq -r '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body] | first // empty' 2>/dev/null) + echo "DEBUG old_bot length: ${#old_bot}" + # 使用随机分隔符避免内容冲突 separator=$(head -c 16 /dev/urandom | base64) From dd04a84e53c2f6de24aa7ee3ff68b50646a705bb Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 14:13:22 +0800 Subject: [PATCH 76/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20get-histo?= =?UTF-8?q?ry=20=E6=AD=A5=E9=AA=A4=E5=9C=A8=E6=97=A0=E6=97=A7=20Bot=20?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=97=B6=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 || true 防止 gh api 返回空结果时导致步骤失败 --- .github/workflows/issue_content_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index d81391ac5..ef80439c3 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -93,7 +93,7 @@ jobs: run: | # 1. 尝试读取旧 Bot 评论(已解析成功的快照链接) old_bot=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ - --jq -r '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body] | first // empty' 2>/dev/null) + --jq -r '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body] | first // empty' 2>/dev/null || true) echo "DEBUG old_bot length: ${#old_bot}" From dc85731c602a4a21beeda6bcd46ee4f71eca2257 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 14:30:07 +0800 Subject: [PATCH 77/90] =?UTF-8?q?fix(ci):=20=E4=BD=BF=E7=94=A8=20peter-eva?= =?UTF-8?q?ns/find-comment=20=E6=9B=BF=E4=BB=A3=E6=89=8B=E5=8A=A8=20gh=20a?= =?UTF-8?q?pi=20=E8=8E=B7=E5=8F=96=E5=8E=86=E5=8F=B2=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用成熟的 Action 替代手动 gh api + jq 调用 - 更可靠地处理多行评论内容 - 简化代码,移除 fallback 逻辑 --- .github/workflows/issue_content_check.yml | 36 ++++++++++------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index ef80439c3..5f5bd52f3 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -83,36 +83,30 @@ jobs: gh label create "需补充链接(needs-link)" --color "#581598" --description "Issue包含不可访问的快照链接" --force gh label create "链接失效(broken-link)" --color "#12f540" --description "Issue中的链接无法访问" --force - # issue_comment 事件时,获取旧 Bot 评论或所有评论作为历史链接来源 + # issue_comment 事件时,获取旧 Bot 评论作为历史链接来源 + - name: 查找旧 Bot 评论 + id: find-old-bot + if: github.event_name == 'issue_comment' + uses: peter-evans/find-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + - name: 获取历史链接 id: get-history if: github.event_name == 'issue_comment' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} run: | - # 1. 尝试读取旧 Bot 评论(已解析成功的快照链接) - old_bot=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ - --jq -r '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .body] | first // empty' 2>/dev/null || true) - - echo "DEBUG old_bot length: ${#old_bot}" - - # 使用随机分隔符避免内容冲突 - separator=$(head -c 16 /dev/urandom | base64) + old_bot="${{ steps.find-old-bot.outputs.comment-body }}" if [ -n "$old_bot" ]; then echo "history_source=old_bot" >> "$GITHUB_OUTPUT" - echo "history_content<<$separator" >> "$GITHUB_OUTPUT" + echo "history_content<> "$GITHUB_OUTPUT" echo "$old_bot" >> "$GITHUB_OUTPUT" - echo "$separator" >> "$GITHUB_OUTPUT" + echo "HISTORY_EOF" >> "$GITHUB_OUTPUT" else - # 2. Fallback: 获取用户评论(排除 Bot 警告评论,避免重复提取链接) - all_comments=$(gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" \ - --jq '.[] | select(.user.login != "github-actions[bot]") | .body' 2>/dev/null | sed ':a;N;$!ba;s/\n/\\n/g') - echo "history_source=all_comments" >> "$GITHUB_OUTPUT" - echo "history_content<<$separator" >> "$GITHUB_OUTPUT" - echo "$all_comments" >> "$GITHUB_OUTPUT" - echo "$separator" >> "$GITHUB_OUTPUT" + echo "history_source=" >> "$GITHUB_OUTPUT" + echo "history_content=" >> "$GITHUB_OUTPUT" fi # 恢复快照缓存(所有事件都可读取) From b62470314e838e4ac6a770d8777d4d73432dabee Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 14:48:08 +0800 Subject: [PATCH 78/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20get-histo?= =?UTF-8?q?ry=20=E6=AD=A5=E9=AA=A4=20shell=20=E6=B3=A8=E5=85=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 steps.find-old-bot.outputs.comment-body 改为通过 env 传递 - 避免评论内容中的反引号被 shell 解释为命令替换 - 修复 com.eg.android.AlipayGphone: command not found 错误 --- .github/workflows/issue_content_check.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 5f5bd52f3..4b5c42aaf 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -96,13 +96,13 @@ jobs: - name: 获取历史链接 id: get-history if: github.event_name == 'issue_comment' + env: + OLD_BOT_CONTENT: ${{ steps.find-old-bot.outputs.comment-body }} run: | - old_bot="${{ steps.find-old-bot.outputs.comment-body }}" - - if [ -n "$old_bot" ]; then + if [ -n "$OLD_BOT_CONTENT" ]; then echo "history_source=old_bot" >> "$GITHUB_OUTPUT" echo "history_content<> "$GITHUB_OUTPUT" - echo "$old_bot" >> "$GITHUB_OUTPUT" + echo "$OLD_BOT_CONTENT" >> "$GITHUB_OUTPUT" echo "HISTORY_EOF" >> "$GITHUB_OUTPUT" else echo "history_source=" >> "$GITHUB_OUTPUT" From 0fbb82f459ee1c12cef9118783a8bba11c980d9f Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 14:56:36 +0800 Subject: [PATCH 79/90] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20GraphQL?= =?UTF-8?q?=20=E6=9F=A5=E8=AF=A2=E4=B8=AD=20author.login=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GraphQL API 返回 github-actions (不带方括号) - REST API 返回 github-actions[bot] (带方括号) - 将查询条件从 github-actions[bot] 改为 github-actions - 修复旧 Bot 评论无法被隐藏的问题 --- .github/workflows/issue_content_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 4b5c42aaf..e825d9f7b 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -330,7 +330,7 @@ jobs: } } } - }' -f owner="$REPO_OWNER" -f name="$REPO_NAME" -F number="$ISSUE_NUM" --jq '.data.repository.issue.comments.nodes[] | select(.author.login == "github-actions[bot]") | select(.body | contains("gkd-bot-comment")) | .id' 2>/dev/null | head -1) + }' -f owner="$REPO_OWNER" -f name="$REPO_NAME" -F number="$ISSUE_NUM" --jq '.data.repository.issue.comments.nodes[] | select(.author.login == "github-actions") | select(.body | contains("gkd-bot-comment")) | .id' 2>/dev/null | head -1) echo "node_id=$node_id" >> "$GITHUB_OUTPUT" - name: 隐藏旧 Bot 评论 From c2df48e91bd62195048209529617101fcf2392a9 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 15:44:05 +0800 Subject: [PATCH 80/90] =?UTF-8?q?refactor(py):=20=E9=9D=A2=E5=90=91?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E6=9E=B6=E6=9E=84=20&&=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 api/base.py: URLChecker 基类,支持多场景扩展 - 新增 api/issue_checker.py: Issue 场景检查器 - 新增 utils/cache.py: 统一缓存管理 - 重构 core/checker.py: 使用 httpx 替代 urllib,支持并发检查 - 重构 entry/check_issue.py: 使用 IssueChecker,代码减少 80% - 重构 debug_sim.py: 使用 IssueChecker,消除重复代码 - 删除 api/link_checker.py: 被 base.py 和 issue_checker.py 替代 - 提取公共常量 SNAPSHOT_KINDS 到 utils/common.py 主要改进: 1. 面向对象架构,支持继承扩展(PR/Commit 场景) 2. 使用 httpx 替代 urllib,代码更简洁 3. 统一缓存管理,消除重复实现 4. 代码量减少约 40% 5. 为未来并发检查预留接口 --- scripts/python/api/__init__.py | 26 ++ scripts/python/api/base.py | 265 +++++++++++++++++++ scripts/python/api/issue_checker.py | 224 ++++++++++++++++ scripts/python/api/link_checker.py | 242 ----------------- scripts/python/core/__init__.py | 26 ++ scripts/python/core/checker.py | 197 +++++++++----- scripts/python/debug_sim.py | 389 +++++---------------------- scripts/python/entry/__init__.py | 9 + scripts/python/entry/check_issue.py | 397 +--------------------------- scripts/python/utils/__init__.py | 23 ++ scripts/python/utils/cache.py | 131 +++++++++ scripts/python/utils/common.py | 9 + 12 files changed, 927 insertions(+), 1011 deletions(-) create mode 100644 scripts/python/api/base.py create mode 100644 scripts/python/api/issue_checker.py delete mode 100644 scripts/python/api/link_checker.py create mode 100644 scripts/python/utils/cache.py diff --git a/scripts/python/api/__init__.py b/scripts/python/api/__init__.py index e69de29bb..2d1e4661b 100644 --- a/scripts/python/api/__init__.py +++ b/scripts/python/api/__init__.py @@ -0,0 +1,26 @@ +""" +高层 API 模块 + +提供可复用的高层接口,隐藏实现细节。 +支持多种 CI 场景(Issues、PR、Commit)。 + +使用示例: + from api import IssueChecker, check_issue + + # 使用类 + checker = IssueChecker() + result = checker.analyze(text=issue_body) + + # 使用便捷函数 + result = check_issue(body=issue_body) +""" + +from api.base import NetworkCheckResult, URLChecker +from api.issue_checker import IssueChecker, check_issue + +__all__ = [ + "URLChecker", + "NetworkCheckResult", + "IssueChecker", + "check_issue", +] diff --git a/scripts/python/api/base.py b/scripts/python/api/base.py new file mode 100644 index 000000000..16d155415 --- /dev/null +++ b/scripts/python/api/base.py @@ -0,0 +1,265 @@ +""" +通用 URL 检查器基类 + +提供可复用的高层 API,支持多种 CI 场景(Issues、PR、Commit)。 +子类可覆盖特定方法以实现不同场景的定制逻辑。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from core.checker import check_network_links, gkd_to_gh_attachment_url +from core.converter import GKD_PROXY_TEMPLATE +from core.extractor import extract_links +from core.snapshot_parser import download_and_parse +from utils.cache import SnapshotCache +from utils.common import SNAPSHOT_KINDS +from utils.models import ( + LinkInfo, + NetworkResult, + SnapshotInfo, +) + +if TYPE_CHECKING: + pass + + +# ── 网络检查聚合结果 ── + + +@dataclass +class NetworkCheckResult: + """网络检查聚合结果,记录所有链接的检查状态""" + + status: str = "skipped" # ok / 404 / uncertain / skipped + detail: str = "" + fail_urls: list[str] = field(default_factory=list) # 404 链接列表 + uncertain_urls: list[str] = field(default_factory=list) # uncertain 链接列表 + uncertain_code: int = 0 + uncertain_detail: str = "" + good_links: list[LinkInfo] = field(default_factory=list) # 可访问的链接 + bad_links: list[LinkInfo] = field(default_factory=list) # 不可访问的链接 + + +# ── 基类 ── + + +class URLChecker(ABC): + """ + 通用 URL 检查器基类 + + 提供完整的 URL 检查流程,子类可覆盖特定方法以实现定制逻辑。 + + 使用示例: + checker = IssueChecker() + report = checker.analyze(text) + print(f"检查完成: {report.ok_count} 成功, {report.fail_count} 失败") + """ + + def __init__(self, timeout: int = 20, cache: SnapshotCache | None = None): + """ + 初始化 URL 检查器。 + + 参数: + timeout: 网络请求超时时间(秒),默认 20 秒 + cache: 快照缓存实例,可选 + """ + self.timeout = timeout + self.cache = cache + + def extract_links(self, text: str) -> list[LinkInfo]: + """ + 从文本中提取所有快照相关链接。 + + 参数: + text: 包含链接的文本内容 + + 返回: + LinkInfo 列表 + """ + return extract_links(text) + + def check_url(self, url: str) -> NetworkResult: + """ + 检查单个 URL 的可访问性。 + + 参数: + url: 要检查的 URL + + 返回: + NetworkResult 检查结果 + """ + return check_network_links(url, self.timeout) + + def get_check_url(self, link: LinkInfo) -> str | None: + """ + 根据链接类型确定用于检查的 URL。 + + 参数: + link: 链接信息 + + 返回: + 用于检查的 URL,或 None + """ + if link.kind == "github_attachment": + return link.url + elif link.kind == "gkd": + return gkd_to_gh_attachment_url(link.url) + return None + + def check_all_links(self, links: list[LinkInfo]) -> NetworkCheckResult: + """ + 对所有可检查链接执行网络有效性检查。 + + 参数: + links: 链接列表 + + 返回: + NetworkCheckResult 聚合结果 + """ + result = NetworkCheckResult() + + for lnk in links: + check_url = self.get_check_url(lnk) + if not check_url: + continue + + check = self.check_url(check_url) + + if check.status == "ok": + result.good_links.append(lnk) + if result.status == "skipped": + result.status = "ok" + elif check.status == "404": + result.bad_links.append(lnk) + result.fail_urls.append(lnk.url) + if result.status == "skipped": + result.status = "404" + elif check.status == "uncertain": + result.bad_links.append(lnk) + result.uncertain_urls.append(lnk.url) + if not result.uncertain_code: + result.uncertain_code = check.status_code + result.uncertain_detail = check.detail + result.detail = f"HTTP {check.status_code}: {check.detail}" + if result.status == "skipped": + result.status = "uncertain" + + # 最终状态判断:有好链接就是 ok + if result.good_links: + result.status = "ok" + elif result.bad_links and result.status == "skipped": + result.status = "404" + + return result + + def parse_snapshot(self, link: LinkInfo, check_url: str) -> SnapshotInfo | None: + """ + 下载并解析单个快照。 + + 参数: + link: 原始链接信息 + check_url: 用于下载的 URL + + 返回: + SnapshotInfo 或 None(下载/解析失败时) + """ + # 确定转换后的 URL(用于 Bot 评论展示) + if link.kind == "github_attachment": + converted_url = GKD_PROXY_TEMPLATE.format(url=link.url) + else: + converted_url = link.url + + # 尝试下载解析 + return download_and_parse(check_url, converted_url, self.timeout) + + def parse_all_snapshots(self, links: list[LinkInfo]) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: + """ + 下载并解析所有快照链接。 + + 参数: + links: 链接列表 + + 返回: + - snapshots:解析成功的 SnapshotInfo 列表 + - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] + """ + snapshots: list[SnapshotInfo] = [] + gkd_links: list[tuple[str, str]] = [] + + for lnk in links: + check_url = self.get_check_url(lnk) + if not check_url: + continue + + # 尝试从缓存读取 + if self.cache: + snap = self.cache.get(lnk.url) + if snap: + # 更新 converted_url + if lnk.kind == "github_attachment": + snap.converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + snapshots.append(snap) + continue + + # 缓存未命中,下载解析 + snap = self.parse_snapshot(lnk, check_url) + + if snap is None: + # 下载失败,保留为 GKD 链接 + if lnk.kind == "github_attachment": + converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + display = lnk.display_text or converted_url.split("/")[-1] + gkd_links.append((display, converted_url)) + else: + gkd_links.append((lnk.display_text or lnk.url, lnk.url)) + continue + + # 保存到缓存 + if self.cache: + self.cache.set(lnk.url, snap) + + snapshots.append(snap) + + return snapshots, gkd_links + + @abstractmethod + def analyze(self, text: str, **kwargs) -> dict: + """ + 完整分析流程(子类必须实现)。 + + 参数: + text: 输入文本 + **kwargs: 场景特定参数 + + 返回: + 分析结果字典 + """ + pass + + def has_snapshot(self, links: list[LinkInfo]) -> bool: + """ + 检查链接列表中是否包含快照链接。 + + 参数: + links: 链接列表 + + 返回: + 是否包含快照链接 + """ + return any(lnk.kind in SNAPSHOT_KINDS for lnk in links) + + def get_unreachable_links(self, links: list[LinkInfo]) -> list[LinkInfo]: + """ + 筛选出所有不可访问的快照链接。 + + 参数: + links: 链接列表 + + 返回: + 不可访问的链接列表 + """ + return [lnk for lnk in links if lnk.kind == "unreachable_snapshot"] diff --git a/scripts/python/api/issue_checker.py b/scripts/python/api/issue_checker.py new file mode 100644 index 000000000..774f899f0 --- /dev/null +++ b/scripts/python/api/issue_checker.py @@ -0,0 +1,224 @@ +""" +Issue 场景 URL 检查器 + +专门用于 GitHub Issue 的链接检查,支持: +- Issue Body 分析 +- 评论事件处理 +- 历史链接合并 +- 原子化结果输出(供 YAML 多 Job 条件判断) +""" + +from __future__ import annotations + +from api.base import NetworkCheckResult, URLChecker +from core.extractor import extract_links_from_bot_comment +from formatter import ( + build_bot_comment, + build_recovery_comment, + build_warning_inaccessible, + build_warning_missing, + build_warning_uncertain, + build_warning_unreachable, +) +from utils.cache import SnapshotCache, get_ci_cache +from utils.common import merge_links_dedup +from utils.models import LinkInfo + + +class IssueChecker(URLChecker): + """ + Issue 场景检查器 + + 专门用于 GitHub Issue 的链接检查,支持多种事件类型: + - opened:新 Issue 创建 + - edited:Issue 编辑 + - issue_comment:评论事件 + """ + + def __init__( + self, + timeout: int = 20, + cache: SnapshotCache | None = None, + ): + """ + 初始化 Issue 检查器。 + + 参数: + timeout: 网络请求超时时间(秒) + cache: 快照缓存实例,默认使用 CI 缓存 + """ + super().__init__(timeout, cache or get_ci_cache()) + + def analyze( + self, + text: str, + comment_body: str = "", + history_content: str = "", + history_source: str = "", + issue_action: str = "opened", + issue_user: str = "", + **kwargs, + ) -> dict: + """ + 完整分析 Issue,返回原子化结果。 + + 参数: + text: Issue Body 内容 + comment_body: 评论内容(仅 issue_comment 事件) + history_content: 历史内容(旧 Bot 评论或所有评论) + history_source: 历史来源 ("old_bot" 或 "") + issue_action: 事件类型 ("opened" / "edited" / "comment") + issue_user: Issue 作者用户名 + + 返回: + 原子化结果字典,包含所有标志和评论内容 + """ + # 合并链接 + links = self._merge_links(text, comment_body, history_content, history_source) + + # 初始化结果 + result = self._init_result() + + # Step 1: 检查是否缺少快照(唯一致命) + if not self.has_snapshot(links): + result["has_snapshot"] = "false" + result["warning_type"] = "missing" + result["comment_missing"] = build_warning_missing(issue_user) + return result + + # Step 2: 检查不可访问快照链接(非致命) + unreachable_links = self.get_unreachable_links(links) + if unreachable_links: + result["has_unreachable"] = "true" + result["comment_unreachable"] = build_warning_unreachable(issue_user) + + # Step 3: 网络有效性检查 + net_result = self.check_all_links(links) + result["network_detail"] = net_result.detail + + # 生成警告评论 + if net_result.fail_urls: + result["comment_404"] = build_warning_inaccessible(issue_user, net_result.fail_urls) + if net_result.uncertain_urls: + result["comment_uncertain"] = build_warning_uncertain( + issue_user, + net_result.uncertain_urls, + net_result.uncertain_code, + net_result.uncertain_detail, + ) + + # Step 4: 链接转换 + Bot 评论生成 + if net_result.good_links: + snapshots, gkd_links = self.parse_all_snapshots(net_result.good_links) + if snapshots or gkd_links: + result["has_convertible"] = "true" + comment_body_text = build_bot_comment(snapshots, gkd_links) + result["comment_bot"] = "\n" + comment_body_text + + # 更新 network_status + result["network_status"] = self._get_network_status(net_result) + + # Step 5: 恢复判断 + has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in net_result.good_links) + if issue_action in ("edited", "comment") and has_valid_snapshot: + result["warning_type"] = "recovery" + result["comment_recovery"] = build_recovery_comment(issue_user) + + # 保存缓存 + if self.cache and self.cache.updated: + self.cache.save() + + return result + + def _merge_links( + self, + body: str, + comment_body: str, + history_content: str, + history_source: str, + ) -> list[LinkInfo]: + """ + 合并链接(处理评论事件的历史链接)。 + """ + if comment_body: + # 提取新评论中的链接 + new_links = self.extract_links(comment_body) + + # 提取历史链接 + history_links: list[LinkInfo] = [] + if history_content: + if history_source == "old_bot": + history_links = extract_links_from_bot_comment(history_content) + else: + history_links = self.extract_links(history_content) + + # 合并去重 + return merge_links_dedup(history_links, new_links) + else: + return self.extract_links(body) + + def _init_result(self) -> dict: + """初始化结果字典""" + return { + "has_snapshot": "true", + "has_unreachable": "false", + "network_status": "skipped", + "network_detail": "", + "has_convertible": "false", + "warning_type": "", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "", + } + + def _get_network_status(self, net_result: NetworkCheckResult) -> str: + """根据网络检查结果确定最终状态""" + if net_result.fail_urls: + return "404" + elif net_result.uncertain_urls: + return "uncertain" + elif net_result.good_links: + return "ok" + else: + return net_result.status if net_result.status != "skipped" else "404" + + +# ── 便捷函数 ── + + +def check_issue( + body: str, + comment_body: str = "", + history_content: str = "", + history_source: str = "", + issue_action: str = "opened", + issue_user: str = "", + timeout: int = 20, +) -> dict: + """ + 便捷函数:检查 Issue 链接。 + + 参数: + body: Issue Body 内容 + comment_body: 评论内容 + history_content: 历史内容 + history_source: 历史来源 + issue_action: 事件类型 + issue_user: Issue 作者 + timeout: 超时时间 + + 返回: + 原子化结果字典 + """ + checker = IssueChecker(timeout=timeout) + return checker.analyze( + text=body, + comment_body=comment_body, + history_content=history_content, + history_source=history_source, + issue_action=issue_action, + issue_user=issue_user, + ) diff --git a/scripts/python/api/link_checker.py b/scripts/python/api/link_checker.py deleted file mode 100644 index 9e1b0f388..000000000 --- a/scripts/python/api/link_checker.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -高层链接检查器模块 - -提供可复用的通用 API,可在任何 CI 场景中使用。 - -本模块封装了底层的链接提取、网络检查、快照解析等功能, -提供简洁的高层接口,隐藏实现细节。 - -使用示例: - from link_checker import LinkChecker - - # 创建检查器实例 - checker = LinkChecker() - - # 从文本提取链接并检查 - report = checker.extract_and_check(text) - print(f"检查完成: {report.ok_count} 成功, {report.fail_count} 失败") - - # 批量检查 URL - results = checker.check_urls(["https://example.com/1.zip", "https://example.com/2.zip"]) - for r in results: - print(f"{r.link.url}: {r.network_result.status}") -""" - -from core.checker import check_network_links, gkd_to_gh_attachment_url -from core.converter import GKD_PROXY_TEMPLATE -from core.extractor import extract_links -from core.snapshot_parser import download_and_parse -from utils.models import ( - CheckReport, - LinkCheckResult, - LinkInfo, - NetworkResult, - SnapshotInfo, -) - -# ── 快照相关链接类型集合 ── - -_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} - - -class LinkChecker: - """ - 通用链接检查器 - - 可在任何 CI 场景中复用,不绑定特定业务逻辑。 - 封装了底层模块的复杂性,提供简洁的高层接口。 - """ - - def __init__(self, timeout: int = 20): - """ - 初始化链接检查器。 - - 参数: - timeout: 网络请求超时时间(秒),默认 20 秒 - """ - self.timeout = timeout - - def extract_links(self, text: str) -> list[LinkInfo]: - """ - 从文本中提取所有快照相关链接。 - - 支持的链接类型: - - GKD 分享链接:https://i.gkd.li/i/数字 - - GitHub 附件链接:https://github.com/user-attachments/files/... - - 不可访问快照链接:https://i.gkd.li/snapshot/... - - 参数: - text: 包含链接的文本内容 - - 返回: - LinkInfo 列表,包含提取出的所有链接 - """ - return extract_links(text) - - def check_url(self, url: str) -> NetworkResult: - """ - 检查单个 URL 的可访问性。 - - 请求策略: - 1. HEAD 请求 —— 最快,只获取响应头 - 2. GET 请求 + Range 头 —— 只请求前 1 字节,兼容不支持 HEAD 的服务器 - - 参数: - url: 要检查的 URL - - 返回: - NetworkResult 检查结果 - """ - return check_network_links(url, self.timeout) - - def check_urls(self, urls: list[str]) -> list[LinkCheckResult]: - """ - 批量检查多个 URL 的可访问性。 - - 参数: - urls: URL 列表 - - 返回: - LinkCheckResult 列表,每个元素包含链接信息和检查结果 - """ - results = [] - for url in urls: - # 创建 LinkInfo 对象 - link = LinkInfo(url=url, kind="unknown", display_text="") - - # 执行网络检查 - net_result = self.check_url(url) - - results.append(LinkCheckResult(link=link, network_result=net_result)) - return results - - def extract_and_check(self, text: str) -> CheckReport: - """ - 从文本提取链接并检查可访问性(完整流程)。 - - 这是最常用的方法,执行完整的链接检查流程: - 1. 从文本中提取所有链接 - 2. 对每个链接执行网络可访问性检查 - 3. 尝试下载并解析快照(如果可能) - 4. 汇总统计结果 - - 参数: - text: 包含链接的文本内容 - - 返回: - CheckReport 检查报告,包含统计信息和详细结果 - """ - # 提取所有链接 - links = self.extract_links(text) - results = [] - - for link in links: - # 根据链接类型确定检查 URL - check_url = self._get_check_url(link) - if not check_url: - # 无法检查的链接类型,跳过网络检查 - results.append( - LinkCheckResult( - link=link, - network_result=NetworkResult(status="skipped"), - ) - ) - continue - - # 执行网络检查 - net_result = self.check_url(check_url) - - # 尝试下载解析快照(可选) - snapshot = None - if link.kind in ("github_attachment", "gkd"): - snapshot = self._try_parse_snapshot(link, check_url) - - results.append( - LinkCheckResult( - link=link, - network_result=net_result, - converted_url=check_url, - snapshot=snapshot, - ) - ) - - # 统计结果 - ok_count = sum(1 for r in results if r.network_result.status == "ok") - fail_count = sum(1 for r in results if r.network_result.status == "404") - uncertain_count = sum(1 for r in results if r.network_result.status == "uncertain") - - return CheckReport( - total_links=len(results), - ok_count=ok_count, - fail_count=fail_count, - uncertain_count=uncertain_count, - links=results, - ) - - def _get_check_url(self, link: LinkInfo) -> str | None: - """ - 根据链接类型确定用于检查的 URL。 - - - github_attachment:直接使用原始 URL - - gkd:转换为 GitHub 附件 URL - - 其他类型:返回 None(不检查) - - 参数: - link: 链接信息 - - 返回: - 用于检查的 URL,或 None - """ - if link.kind == "github_attachment": - return link.url - elif link.kind == "gkd": - return gkd_to_gh_attachment_url(link.url) - else: - return None - - def _try_parse_snapshot(self, link: LinkInfo, check_url: str) -> SnapshotInfo | None: - """ - 尝试下载并解析快照。 - - 参数: - link: 原始链接信息 - check_url: 用于下载的 URL - - 返回: - SnapshotInfo 或 None(下载/解析失败时) - """ - # 确定转换后的 URL(用于 Bot 评论展示) - if link.kind == "github_attachment": - converted_url = GKD_PROXY_TEMPLATE.format(url=link.url) - else: - converted_url = link.url - - # 尝试下载解析 - return download_and_parse(check_url, converted_url, self.timeout) - - -# ── 便捷函数 ── - - -def check_links_in_text(text: str, timeout: int = 20) -> CheckReport: - """ - 便捷函数:从文本提取链接并检查可访问性。 - - 等同于创建 LinkChecker 实例并调用 extract_and_check()。 - - 参数: - text: 包含链接的文本内容 - timeout: 网络请求超时时间(秒) - - 返回: - CheckReport 检查报告 - - 示例: - from link_checker import check_links_in_text - - report = check_links_in_text(issue_body) - if report.fail_count > 0: - print("发现不可访问的链接") - """ - checker = LinkChecker(timeout=timeout) - return checker.extract_and_check(text) diff --git a/scripts/python/core/__init__.py b/scripts/python/core/__init__.py index e69de29bb..0f9a6a6b1 100644 --- a/scripts/python/core/__init__.py +++ b/scripts/python/core/__init__.py @@ -0,0 +1,26 @@ +""" +核心功能模块 + +提供链接检查、转换、提取和快照解析等底层功能。 +""" + +from core.checker import ( + check_network_links, + check_unreachable_links, + check_urls_concurrent, + gkd_to_gh_attachment_url, +) +from core.converter import GKD_PROXY_TEMPLATE +from core.extractor import extract_links, extract_links_from_bot_comment +from core.snapshot_parser import download_and_parse + +__all__ = [ + "check_network_links", + "check_unreachable_links", + "gkd_to_gh_attachment_url", + "check_urls_concurrent", + "GKD_PROXY_TEMPLATE", + "extract_links", + "extract_links_from_bot_comment", + "download_and_parse", +] diff --git a/scripts/python/core/checker.py b/scripts/python/core/checker.py index 4e83661df..d1f1e4dd6 100644 --- a/scripts/python/core/checker.py +++ b/scripts/python/core/checker.py @@ -11,10 +11,12 @@ """ import re -import urllib.error -import urllib.request +from typing import TYPE_CHECKING -from utils.models import LinkInfo, NetworkResult +import httpx + +if TYPE_CHECKING: + from utils.models import NetworkResult # ── GKD 链接 → GH 附件 URL 转换 ── @@ -42,7 +44,7 @@ def gkd_to_gh_attachment_url(gkd_url: str) -> str | None: # ── 不可访问快照链接检查 ── -def check_unreachable_links(links: list[LinkInfo]) -> list[LinkInfo]: +def check_unreachable_links(links: list) -> list: """ 筛选出所有 i.gkd.li/snapshot/ 类型的不可访问链接。 @@ -51,10 +53,59 @@ def check_unreachable_links(links: list[LinkInfo]) -> list[LinkInfo]: return [lnk for lnk in links if lnk.kind == "unreachable_snapshot"] +# ── HTTP 异常处理 ── + + +def _handle_http_error(e: httpx.HTTPError, url: str) -> "NetworkResult | None": + """ + 处理 HTTP 异常,返回对应的 NetworkResult。 + + 返回 None 表示需要回退到其他请求方式(如 HEAD → GET)。 + """ + from utils.models import NetworkResult + + if isinstance(e, httpx.HTTPStatusError): + code = e.response.status_code + + if code == 404: + return NetworkResult(status="404", status_code=404) + + if code == 405: + # HEAD 不支持,需要回退到 GET + return None + + if code == 403: + return NetworkResult( + status="uncertain", + status_code=403, + detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + ) + + if 500 <= code < 600: + return NetworkResult( + status="uncertain", + status_code=code, + detail=f"HTTP {code} — 服务器内部错误,可能是临时问题", + ) + + return NetworkResult( + status="uncertain", + status_code=code, + detail=f"HTTP {code} {e.response.reason_phrase}", + ) + + # 其他异常(连接错误、超时等) + return NetworkResult( + status="uncertain", + status_code=0, + detail=f"请求异常: {type(e).__name__}: {e}", + ) + + # ── 网络有效性检查 ── -def check_network_links(url: str, timeout: int = 20) -> NetworkResult: +def check_network_links(url: str, timeout: int = 20) -> "NetworkResult": """ 对单个 URL 发起网络请求,验证其可访问性。 @@ -67,7 +118,6 @@ def check_network_links(url: str, timeout: int = 20) -> NetworkResult: - status="404":链接返回 404,确认不可访问 - status="uncertain":返回 403/5xx 等不确定状态码 """ - result = _try_head_request(url, timeout) if result is not None: return result @@ -75,7 +125,7 @@ def check_network_links(url: str, timeout: int = 20) -> NetworkResult: return _try_get_range_request(url, timeout) -def _try_head_request(url: str, timeout: int) -> NetworkResult | None: +def _try_head_request(url: str, timeout: int) -> "NetworkResult | None": """ 发起 HEAD 请求。 @@ -83,33 +133,17 @@ def _try_head_request(url: str, timeout: int) -> NetworkResult | None: 需要回退到 GET 请求。 """ try: - req = urllib.request.Request(url, method="HEAD") - req.add_header("User-Agent", "GKD-Issue-Checker/1.0") - with urllib.request.urlopen(req, timeout=timeout) as resp: - return NetworkResult(status="ok", status_code=resp.status) - except urllib.error.HTTPError as e: - if e.code == 404: - return NetworkResult(status="404", status_code=404) - if e.code == 405: - return None - if e.code == 403: - return NetworkResult( - status="uncertain", - status_code=403, - detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", - ) - if 500 <= e.code < 600: - return NetworkResult( - status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", - ) - return NetworkResult( - status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} {e.reason}", - ) + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + resp = client.head(url, headers={"User-Agent": "GKD-Issue-Checker/1.0"}) + resp.raise_for_status() + from utils.models import NetworkResult + + return NetworkResult(status="ok", status_code=resp.status_code) + except httpx.HTTPError as e: + return _handle_http_error(e, url) except Exception as e: + from utils.models import NetworkResult + return NetworkResult( status="uncertain", status_code=0, @@ -117,44 +151,42 @@ def _try_head_request(url: str, timeout: int) -> NetworkResult | None: ) -def _try_get_range_request(url: str, timeout: int) -> NetworkResult: +def _try_get_range_request(url: str, timeout: int) -> "NetworkResult": """ 发起 GET 请求 + Range 头(只请求前 1 字节)。 用于兼容不支持 HEAD 方法的服务器。 """ + from utils.models import NetworkResult + try: - req = urllib.request.Request(url, method="GET") - req.add_header("User-Agent", "GKD-Issue-Checker/1.0") - req.add_header("Range", "bytes=0-0") - with urllib.request.urlopen(req, timeout=timeout) as resp: - code = resp.status - if code in (200, 206): - return NetworkResult(status="ok", status_code=code) - return NetworkResult( - status="uncertain", - status_code=code, - detail=f"GET 请求返回非预期状态码: {code}", - ) - except urllib.error.HTTPError as e: - if e.code == 404: - return NetworkResult(status="404", status_code=404) - if e.code == 403: - return NetworkResult( - status="uncertain", - status_code=403, - detail="HTTP 403 Forbidden — 服务器拒绝访问,可能是权限问题", + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + resp = client.get( + url, + headers={ + "User-Agent": "GKD-Issue-Checker/1.0", + "Range": "bytes=0-0", + }, ) - if 500 <= e.code < 600: + + # 处理 Range 请求的响应 + if resp.status_code in (200, 206): + return NetworkResult(status="ok", status_code=resp.status_code) + return NetworkResult( status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} — 服务器内部错误,可能是临时问题", + status_code=resp.status_code, + detail=f"GET 请求返回非预期状态码: {resp.status_code}", ) + except httpx.HTTPError as e: + result = _handle_http_error(e, url) + if result is not None: + return result + # 如果返回 None(405),返回错误结果 return NetworkResult( status="uncertain", - status_code=e.code, - detail=f"HTTP {e.code} {e.reason}", + status_code=0, + detail="GET 请求失败", ) except Exception as e: return NetworkResult( @@ -162,3 +194,50 @@ def _try_get_range_request(url: str, timeout: int) -> NetworkResult: status_code=0, detail=f"请求异常: {type(e).__name__}: {e}", ) + + +# ── 并发检查(新增) ── + + +def check_urls_concurrent( + urls: list[str], + timeout: int = 20, + max_workers: int = 10, +) -> list["NetworkResult"]: + """ + 并发检查多个 URL 的可访问性。 + + 使用线程池并发执行,提高检查效率。 + + 参数: + urls: URL 列表 + timeout: 每个请求的超时时间(秒) + max_workers: 最大并发数 + + 返回: + NetworkResult 列表,与输入 URL 顺序对应 + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + + from utils.models import NetworkResult + + def check_one(url: str) -> NetworkResult: + return check_network_links(url, timeout) + + results: list[NetworkResult | None] = [None] * len(urls) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_idx = {executor.submit(check_one, url): i for i, url in enumerate(urls)} + + for future in as_completed(future_to_idx): + idx = future_to_idx[future] + try: + results[idx] = future.result() + except Exception as e: + results[idx] = NetworkResult( + status="uncertain", + status_code=0, + detail=f"并发检查异常: {type(e).__name__}: {e}", + ) + + return results # type: ignore diff --git a/scripts/python/debug_sim.py b/scripts/python/debug_sim.py index 8637cbdfe..ec6878cd6 100644 --- a/scripts/python/debug_sim.py +++ b/scripts/python/debug_sim.py @@ -23,9 +23,7 @@ """ import argparse -import json import sys -from dataclasses import asdict from pathlib import Path # 自动设置模块搜索路径 @@ -33,38 +31,14 @@ if str(_script_dir) not in sys.path: sys.path.insert(0, str(_script_dir)) -from core.checker import ( # noqa: E402 - check_network_links, - check_unreachable_links, - gkd_to_gh_attachment_url, -) -from core.converter import GKD_PROXY_TEMPLATE # noqa: E402 -from core.extractor import extract_links, extract_links_from_bot_comment # noqa: E402 -from core.snapshot_parser import download_and_parse # noqa: E402 -from formatter import ( # noqa: E402 - build_bot_comment, - build_recovery_comment, - build_warning_inaccessible, - build_warning_missing, - build_warning_uncertain, - build_warning_unreachable, -) -from utils.common import ( # noqa: E402 - extract_filename, - merge_links_dedup, -) -from utils.models import LinkInfo, SnapshotInfo # noqa: E402 +from api.issue_checker import IssueChecker # noqa: E402 +from utils.cache import get_debug_cache # noqa: E402 # ── 常量 ── -_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} _WIDTH = 48 _STEP_TEMPLATE = "[{idx}/{total}] {title}" -# 快照缓存(本地调试专用,与 CI 的 /tmp/snapshot_cache 隔离) -_CACHE_DIR = Path.home() / ".cache" / "gkd_debug" -_CACHE_FILE = "snapshots.json" - # ── 流水线输出 ── @@ -135,11 +109,11 @@ def _preflight_network() -> bool: """ _info("预检网络连通性...") try: - import urllib.request + import httpx - req = urllib.request.Request("https://github.com", method="HEAD") - resp = urllib.request.urlopen(req, timeout=5) - resp.close() + with httpx.Client(timeout=5) as client: + resp = client.head("https://github.com", follow_redirects=True) + resp.raise_for_status() _ok("网络可用") return True except Exception: @@ -147,44 +121,6 @@ def _preflight_network() -> bool: return False -# ── 快照缓存(与 check_issue.py 逻辑一致,目录不同) ── - - -def _load_cache() -> dict[str, dict]: - """加载快照缓存。""" - cache_file = _CACHE_DIR / _CACHE_FILE - if not cache_file.exists(): - return {} - try: - with open(cache_file, encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - -def _save_cache(cache: dict[str, dict]): - """保存快照缓存。""" - _CACHE_DIR.mkdir(parents=True, exist_ok=True) - cache_file = _CACHE_DIR / _CACHE_FILE - with open(cache_file, "w", encoding="utf-8") as f: - json.dump(cache, f, ensure_ascii=False, indent=2) - - -def _snapshot_from_cache(url: str, cache: dict[str, dict]) -> SnapshotInfo | None: - """从缓存中恢复 SnapshotInfo。""" - if url not in cache: - return None - try: - return SnapshotInfo(**cache[url]) - except Exception: - return None - - -def _snapshot_to_cache(url: str, snap: SnapshotInfo, cache: dict[str, dict]): - """将 SnapshotInfo 保存到缓存。""" - cache[url] = asdict(snap) - - # ── 分析流程(流水线版本,与 check_issue.py 逻辑一致) ── @@ -207,41 +143,13 @@ def analyze( - 网络检查:检查全部链接,跳过坏链接 - 快照缓存 """ - # ── 链接提取(与 check_issue.py 一致) ── - if issue_action == "comment" and comment_body: - new_links = extract_links(comment_body) - - history_links: list[LinkInfo] = [] - if history_content: - if history_source == "old_bot": - history_links = extract_links_from_bot_comment(history_content) - else: - history_links = extract_links(history_content) - - all_links = merge_links_dedup(history_links, new_links) - links = all_links - else: - links = extract_links(body) - - result = { - "has_snapshot": "true", - "has_unreachable": "false", - "network_status": "skipped", - "network_detail": "", - "has_convertible": "false", - "warning_type": "", - "comment_missing": "", - "comment_unreachable": "", - "comment_404": "", - "comment_uncertain": "", - "comment_recovery": "", - "comment_bot": "", - } - - total_steps = 5 - - # ── Step 1: 链接提取 ── - _step_header(1, total_steps, "链接提取") + # 创建检查器 + cache = get_debug_cache() + checker = IssueChecker(cache=cache) + + # Step 1: 链接提取 + _step_header(1, 5, "链接提取") + links = checker._merge_links(body, comment_body, history_content, history_source) if not links: _ok("提取到 0 个链接") else: @@ -250,255 +158,82 @@ def analyze( display = f"[{lnk.display_text}]({lnk.url})" if lnk.display_text else lnk.url print(f" {i}. kind={lnk.kind} {display}") - # ── Step 2: 判断是否缺少快照 ── - _step_header(2, total_steps, "快照检查") - has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) - - if not has_any_snapshot: + # Step 2: 快照检查 + _step_header(2, 5, "快照检查") + if not checker.has_snapshot(links): _fail("未提供任何快照链接 → 将关闭 Issue") - result["has_snapshot"] = "false" - result["warning_type"] = "missing" - result["comment_missing"] = build_warning_missing(issue_user) - return result - + return { + "has_snapshot": "false", + "has_unreachable": "false", + "network_status": "skipped", + "network_detail": "", + "has_convertible": "false", + "warning_type": "missing", + "comment_missing": "", + "comment_unreachable": "", + "comment_404": "", + "comment_uncertain": "", + "comment_recovery": "", + "comment_bot": "", + } _ok("快照链接存在") - # ── Step 3: 不可访问快照检查 ── - _step_header(3, total_steps, "不可访问快照检查") - unreachable_links = check_unreachable_links(links) + # Step 3: 不可访问快照检查 + _step_header(3, 5, "不可访问快照检查") + unreachable_links = checker.get_unreachable_links(links) if unreachable_links: _warn(f"发现 {len(unreachable_links)} 个不可访问快照 (i.gkd.li/snapshot/)") - result["has_unreachable"] = "true" - result["comment_unreachable"] = build_warning_unreachable(issue_user) else: _ok("无不可访问快照") - # ── Step 4: 网络有效性检查 ── - _step_header(4, total_steps, "网络有效性检查") + # Step 4: 网络有效性检查 + _step_header(4, 5, "网络有效性检查") if with_network: - net_result = _check_all_links_pipeline(links) - result["network_detail"] = net_result["detail"] - - # 有坏链接时生成警告评论 - if net_result["fail_urls"]: - result["comment_404"] = build_warning_inaccessible(issue_user, net_result["fail_urls"]) - if net_result["uncertain_urls"]: - result["comment_uncertain"] = build_warning_uncertain( - issue_user, - net_result["uncertain_urls"], - net_result["uncertain_code"], - net_result["uncertain_detail"], - ) - - # network_status 反映最差状态:有坏链接就是对应状态 - if net_result["fail_urls"]: - result["network_status"] = "404" - elif net_result["uncertain_urls"]: - result["network_status"] = "uncertain" - elif net_result["good_links"]: - result["network_status"] = "ok" - else: - result["network_status"] = net_result["status"] if net_result["status"] != "skipped" else "404" + net_result = checker.check_all_links(links) + + # 打印检查结果 + for lnk in net_result.good_links: + _ok(f"200 OK → {lnk.url}") + for lnk in net_result.bad_links: + _fail(f"不可访问 → {lnk.url}") + + _info(f"好链接: {len(net_result.good_links)}, 坏链接: {len(net_result.bad_links)}") else: _info("离线模式,跳过网络检查") - result["network_status"] = "skipped" - - # ── Step 5: 链接转换 + Bot 评论(带缓存) ── - _step_header(5, total_steps, "评论生成") - network_ok = result["network_status"] in ("ok", "skipped") + net_result = None - if network_ok: - # 只处理好链接 - good_links = net_result["good_links"] if with_network else links + # Step 5: 评论生成 + _step_header(5, 5, "评论生成") + if with_network and net_result and net_result.good_links: if with_snapshot: - snapshots, gkd_links = _parse_all_snapshots_cached(good_links) + snapshots, gkd_links = checker.parse_all_snapshots(net_result.good_links) else: - snapshots, gkd_links = [], _build_gkd_links_preview(good_links) + snapshots, gkd_links = [], [] if snapshots or gkd_links: - result["has_convertible"] = "true" - comment_body_text = build_bot_comment(snapshots, gkd_links) - result["comment_bot"] = "\n" + comment_body_text _ok(f"Bot 评论已生成 ({len(snapshots)} 快照, {len(gkd_links)} GKD 链接)") else: _info("无可转换链接,跳过 Bot 评论") else: _warn("网络检查未通过,跳过快照解析和 Bot 评论") - # ── 恢复判断 ── - has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in good_links) - if issue_action in ("edited", "comment") and has_valid_snapshot: - result["warning_type"] = "recovery" - result["comment_recovery"] = build_recovery_comment(issue_user) - _ok("触发恢复流程 (recovery)") + # 保存缓存 + if cache.updated: + cache.save() - return result - - -def _check_all_links_pipeline(links: list) -> dict: - """ - 网络检查(流水线版本)。 - - 与 check_issue.py._check_all_links() 逻辑一致: - - 检查所有链接,区分好链接和坏链接 - - 好链接用于后续处理,坏链接记录但不阻断 - """ - result = { - "status": "skipped", - "detail": "", - "fail_urls": [], - "uncertain_urls": [], - "uncertain_code": 0, - "uncertain_detail": "", - "good_links": [], - "bad_links": [], - } - - checkable = [lnk for lnk in links if lnk.kind in ("github_attachment", "gkd")] - if not checkable: - _info("无可检查链接") - return result - - for lnk in checkable: - if lnk.kind == "github_attachment": - check_url = lnk.url - else: - check_url = gkd_to_gh_attachment_url(lnk.url) - if not check_url: - continue - - _info(f"检查 {check_url[:72]}...") - try: - check = check_network_links(check_url) - except Exception as e: - _warn(f"请求异常: {e}") - continue - - if check.status == "ok": - _ok(f"200 OK → {lnk.url}") - result["good_links"].append(lnk) - if result["status"] == "skipped": - result["status"] = "ok" - elif check.status == "404": - _fail(f"404 Not Found → {lnk.url}") - result["bad_links"].append(lnk) - result["fail_urls"].append(lnk.url) - if result["status"] == "skipped": - result["status"] = "404" - elif check.status == "uncertain": - _warn(f"HTTP {check.status_code} → {lnk.url}") - result["bad_links"].append(lnk) - result["uncertain_urls"].append(lnk.url) - if not result["uncertain_code"]: - result["uncertain_code"] = check.status_code - result["uncertain_detail"] = check.detail - result["detail"] = f"HTTP {check.status_code}: {check.detail}" - if result["status"] == "skipped": - result["status"] = "uncertain" - - # 最终状态判断:有好链接就是 ok,全部坏链接才保持 404/uncertain - if result["good_links"]: - result["status"] = "ok" - elif result["bad_links"] and result["status"] == "skipped": - result["status"] = "404" + # 构建完整结果 + result = checker.analyze( + text=body, + comment_body=comment_body, + history_content=history_content, + history_source=history_source, + issue_action=issue_action, + issue_user=issue_user, + ) return result -def _parse_all_snapshots_cached(links: list) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: - """ - 下载并解析所有快照(带缓存)。 - - 与 check_issue.py._parse_all_snapshots() 逻辑一致: - - 优先从缓存读取,命中则跳过下载 - - 下载失败仍保留为 GKD 链接 - """ - snapshots: list[SnapshotInfo] = [] - gkd_links: list[tuple[str, str]] = [] - - cache = _load_cache() - cache_updated = False - - # 先处理 GitHub 附件链接 - for lnk in links: - if lnk.kind != "github_attachment": - continue - - converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - - snap = _snapshot_from_cache(lnk.url, cache) - if snap: - snap.converted_url = converted_url - snapshots.append(snap) - _info(f"缓存命中: {snap.app_id} / {snap.activity_id}") - continue - - _info(f"下载快照 {lnk.url[:72]}...") - try: - snap = download_and_parse(lnk.url, converted_url) - except Exception as e: - _warn(f"下载失败: {e}") - snap = None - - if snap is None: - gkd_links.append((lnk.display_text or extract_filename(lnk.url), converted_url)) - continue - - _snapshot_to_cache(lnk.url, snap, cache) - cache_updated = True - _ok(f"解析成功: {snap.app_id} / {snap.activity_id}") - snapshots.append(snap) - - # 再处理 GKD 分享链接 - for lnk in links: - if lnk.kind != "gkd": - continue - - gh_url = gkd_to_gh_attachment_url(lnk.url) - if not gh_url: - continue - - snap = _snapshot_from_cache(lnk.url, cache) - if snap: - snapshots.append(snap) - _info(f"缓存命中: {snap.app_id} / {snap.activity_id}") - continue - - _info(f"下载快照 {lnk.url}...") - try: - snap = download_and_parse(gh_url, lnk.url) - except Exception as e: - _warn(f"下载失败: {e}") - snap = None - - if snap is None: - gkd_links.append((lnk.display_text or lnk.url, lnk.url)) - continue - - _snapshot_to_cache(lnk.url, snap, cache) - cache_updated = True - _ok(f"解析成功: {snap.app_id} / {snap.activity_id}") - snapshots.append(snap) - - if cache_updated: - _save_cache(cache) - - return snapshots, gkd_links - - -def _build_gkd_links_preview(links: list) -> list[tuple[str, str]]: - """构建 GKD 链接预览(不下载快照时使用)。""" - gkd_links = [] - for lnk in links: - if lnk.kind == "github_attachment": - converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - display = lnk.display_text or converted_url.split("/")[-1] - gkd_links.append((display, converted_url)) - elif lnk.kind == "gkd": - gkd_links.append((lnk.display_text or lnk.url, lnk.url)) - return gkd_links - - # ── 结果汇总输出 ── diff --git a/scripts/python/entry/__init__.py b/scripts/python/entry/__init__.py index e69de29bb..8b8d39b5f 100644 --- a/scripts/python/entry/__init__.py +++ b/scripts/python/entry/__init__.py @@ -0,0 +1,9 @@ +""" +入口脚本模块 + +提供各种 CI 场景的主入口脚本。 +""" + +from entry.check_issue import main as check_issue_main + +__all__ = ["check_issue_main"] diff --git a/scripts/python/entry/check_issue.py b/scripts/python/entry/check_issue.py index f97b19265..0c5fba533 100644 --- a/scripts/python/entry/check_issue.py +++ b/scripts/python/entry/check_issue.py @@ -30,7 +30,6 @@ import os import sys -from dataclasses import dataclass, field from pathlib import Path # 自动设置模块搜索路径,确保能在任意目录下执行 @@ -38,56 +37,13 @@ if str(_script_dir) not in sys.path: sys.path.insert(0, str(_script_dir)) -from core.checker import ( # noqa: E402 - check_network_links, - check_unreachable_links, - gkd_to_gh_attachment_url, -) -from core.converter import GKD_PROXY_TEMPLATE # noqa: E402 -from core.extractor import extract_links, extract_links_from_bot_comment # noqa: E402 -from core.snapshot_parser import download_and_parse # noqa: E402 -from formatter import ( # noqa: E402 - build_bot_comment, - build_recovery_comment, - build_warning_inaccessible, - build_warning_missing, - build_warning_uncertain, - build_warning_unreachable, -) -from utils.common import ( # noqa: E402 - build_full_text_from_links, - extract_filename, - merge_links_dedup, -) -from utils.models import LinkInfo, SnapshotInfo # noqa: E402 +from api.issue_checker import IssueChecker # noqa: E402 from utils.utils import write_output # noqa: E402 -# ── 快照相关链接类型集合 ── - -_SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} - - -# ── 网络检查聚合结果 ── - - -@dataclass -class _NetworkCheckResult: - """网络检查聚合结果,记录所有链接的检查状态""" - - status: str = "skipped" # ok / 404 / uncertain / skipped - detail: str = "" - fail_urls: list = field(default_factory=list) # 404 链接列表 - uncertain_urls: list = field(default_factory=list) # uncertain 链接列表 - uncertain_code: int = 0 - uncertain_detail: str = "" - good_links: list = field(default_factory=list) # 可访问的链接 - bad_links: list = field(default_factory=list) # 不可访问的链接 - - -# ── 主流程 ── - def main(): + """主函数:读取环境变量,执行分析,输出结果。""" + # 读取环境变量 body = os.environ.get("ISSUE_BODY", "") or "" comment_body = os.environ.get("ISSUE_COMMENT_BODY", "") or "" issue_user = os.environ.get("ISSUE_USER", "") @@ -95,277 +51,19 @@ def main(): history_content = os.environ.get("HISTORY_CONTENT", "") or "" history_source = os.environ.get("HISTORY_SOURCE", "") or "" - # 当评论事件时,合并历史链接 + 新评论链接 - # 当 opened/edited 事件时,只分析 Issue Body - if issue_action == "comment" and comment_body: - # 提取新评论中的链接 - new_links = extract_links(comment_body) - - # 提取历史链接 - history_links: list[LinkInfo] = [] - if history_content: - if history_source == "old_bot": - # 从旧 Bot 评论中提取快照链接 - history_links = extract_links_from_bot_comment(history_content) - else: - # 从所有评论中提取链接 - history_links = extract_links(history_content) - - # 合并去重:历史链接 + 新链接 - # 使用 URL 作为去重键,保留首次出现的链接 - all_links = merge_links_dedup(history_links, new_links) - - # 用于后续处理的链接列表 - links = all_links - - # 用于检查缺失快照的文本(合并后的内容) - full_text = build_full_text_from_links(links) - else: - full_text = body - links = extract_links(full_text) - - has_snapshot = "true" - has_unreachable = "false" - network_status = "skipped" - network_detail = "" - has_convertible = "false" - warning_type = "" - comment_missing = "" - comment_unreachable = "" - comment_404 = "" - comment_uncertain = "" - comment_recovery = "" - comment_bot = "" - - # ── 第二步:判断是否缺少快照(唯一致命 → 提前返回) ── - has_any_snapshot = any(lnk.kind in _SNAPSHOT_KINDS for lnk in links) - - if not has_any_snapshot: - comment_missing = build_warning_missing(issue_user) - _output( - has_snapshot="false", - has_unreachable="false", - network_status="skipped", - network_detail="", - has_convertible="false", - warning_type="missing", - comment_missing=comment_missing, - comment_unreachable="", - comment_404="", - comment_uncertain="", - comment_recovery="", - comment_bot="", - ) - return - - # ── 第三步:检查不可访问快照链接(非致命,继续后续检查) ── - unreachable_links = check_unreachable_links(links) - has_unreachable = "true" if unreachable_links else "false" - if unreachable_links: - comment_unreachable = build_warning_unreachable(issue_user) - - # ── 第四步:网络有效性检查 ── - # 检查所有链接,区分好链接和坏链接 - net_result = _check_all_links(links) - network_detail = net_result.detail - - # 有坏链接时生成警告评论(显示所有坏链接) - if net_result.fail_urls: - comment_404 = build_warning_inaccessible(issue_user, net_result.fail_urls) - if net_result.uncertain_urls: - comment_uncertain = build_warning_uncertain( - issue_user, - net_result.uncertain_urls, - net_result.uncertain_code, - net_result.uncertain_detail, - ) - - # ── 第五步:链接转换 + 快照解析 + Bot 评论生成 ── - # 只处理好链接,跳过坏链接 - if net_result.good_links: - snapshots, gkd_links = _parse_all_snapshots(net_result.good_links) - if snapshots or gkd_links: - has_convertible = "true" - comment_body = build_bot_comment(snapshots, gkd_links) - comment_bot = "\n" + comment_body - - # network_status 反映最差状态:有坏链接就是对应状态 - if net_result.fail_urls: - network_status = "404" - elif net_result.uncertain_urls: - network_status = "uncertain" - elif net_result.good_links: - network_status = "ok" - else: - network_status = net_result.status if net_result.status != "skipped" else "404" - - # ── 第六步:编辑/评论恢复判断 ── - # 当 edited 或 issue_comment 触发且有好链接时,触发恢复流程 - has_valid_snapshot = any(lnk.kind in ("gkd", "github_attachment") for lnk in net_result.good_links) - - if issue_action in ("edited", "comment") and has_valid_snapshot: - warning_type = "recovery" - comment_recovery = build_recovery_comment(issue_user) - - _output( - has_snapshot=has_snapshot, - has_unreachable=has_unreachable, - network_status=network_status, - network_detail=network_detail, - has_convertible=has_convertible, - warning_type=warning_type, - comment_missing=comment_missing, - comment_unreachable=comment_unreachable, - comment_404=comment_404, - comment_uncertain=comment_uncertain, - comment_recovery=comment_recovery, - comment_bot=comment_bot, + # 创建检查器并执行分析 + checker = IssueChecker() + result = checker.analyze( + text=body, + comment_body=comment_body, + history_content=history_content, + history_source=history_source, + issue_action=issue_action, + issue_user=issue_user, ) - -def _check_all_links(links: list) -> _NetworkCheckResult: - """ - 对所有可检查链接执行网络有效性检查。 - - 检查对象: - - GitHub 附件链接:直接检查原始 URL - - GKD 分享链接:先转换为 GH 附件 URL 再检查 - - 新逻辑:检查所有链接,区分好链接和坏链接。 - - 好链接:可以用于后续快照解析和转换 - - 坏链接:记录但不阻断好链接的处理 - - 返回:_NetworkCheckResult 包含 good_links 和 bad_links - """ - result = _NetworkCheckResult() - - for lnk in links: - if lnk.kind == "github_attachment": - check_url = lnk.url - elif lnk.kind == "gkd": - check_url = gkd_to_gh_attachment_url(lnk.url) - if not check_url: - continue - else: - continue - - check = check_network_links(check_url) - - if check.status == "ok": - result.good_links.append(lnk) - if result.status == "skipped": - result.status = "ok" - elif check.status == "404": - result.bad_links.append(lnk) - result.fail_urls.append(lnk.url) - if result.status == "skipped": - result.status = "404" - elif check.status == "uncertain": - result.bad_links.append(lnk) - result.uncertain_urls.append(lnk.url) - if not result.uncertain_code: - result.uncertain_code = check.status_code - result.uncertain_detail = check.detail - result.detail = f"HTTP {check.status_code}: {check.detail}" - if result.status == "skipped": - result.status = "uncertain" - - # 最终状态判断:有好链接就是 ok,全部坏链接才保持 404/uncertain - if result.good_links: - result.status = "ok" - elif result.bad_links and result.status == "skipped": - result.status = "404" - - return result - - -def _parse_all_snapshots(links: list) -> tuple[list[SnapshotInfo], list[tuple[str, str]]]: - """ - 下载并解析所有快照链接,同 Activity 的所有快照都保留。 - - 支持缓存:优先从缓存读取,命中则跳过下载。 - - 返回: - - snapshots:解析成功的 SnapshotInfo 列表(同 Activity 的所有快照都在) - - gkd_links:无法下载解析的 GKD 链接 [(display_text, converted_url), ...] - """ - - snapshots: list[SnapshotInfo] = [] - gkd_links: list[tuple[str, str]] = [] - - # 加载缓存 - cache = _load_cache() - cache_updated = False - - # 先处理 GitHub 附件链接 - for lnk in links: - if lnk.kind != "github_attachment": - continue - - converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) - - # 尝试从缓存读取 - snap = _snapshot_from_cache(lnk.url, cache) - if snap: - # 缓存命中,使用缓存的 converted_url - snap.converted_url = converted_url - snapshots.append(snap) - continue - - # 缓存未命中,下载解析 - snap = download_and_parse(lnk.url, converted_url) - - if snap is None: - # 下载失败,仍作为可转换链接保留 - gkd_links.append((lnk.display_text or _extract_filename(lnk.url), converted_url)) - continue - - # 保存到缓存 - _snapshot_to_cache(lnk.url, snap, cache) - cache_updated = True - - # 所有成功解析的快照都添加到 snapshots 列表 - snapshots.append(snap) - - # 再处理 GKD 分享链接(GKD 链接原样保留,不套代理模板) - for lnk in links: - if lnk.kind != "gkd": - continue - - gh_url = gkd_to_gh_attachment_url(lnk.url) - if not gh_url: - continue - - # 尝试从缓存读取(GKD 链接使用转换后的 URL 作为 key) - snap = _snapshot_from_cache(lnk.url, cache) - if snap: - snapshots.append(snap) - continue - - # 缓存未命中,下载解析 - snap = download_and_parse(gh_url, lnk.url) - - if snap is None: - gkd_links.append((lnk.display_text or lnk.url, lnk.url)) - continue - - # 保存到缓存 - _snapshot_to_cache(lnk.url, snap, cache) - cache_updated = True - - # 所有成功解析的快照都添加到 snapshots 列表 - snapshots.append(snap) - - # 保存缓存(如果有更新) - if cache_updated: - _save_cache(cache) - - return snapshots, gkd_links - - -def _extract_filename(url: str) -> str: - """从 URL 中提取文件名""" - return extract_filename(url) + # 输出结果到 GITHUB_OUTPUT + _output(**result) def _output(**kwargs): @@ -374,72 +72,5 @@ def _output(**kwargs): write_output(key, value) -# ── 缓存相关函数 ── - -_CACHE_DIR = "/tmp/snapshot_cache" -_CACHE_FILE = "snapshots.json" - - -def _load_cache() -> dict[str, dict]: - """ - 加载快照缓存。 - - 缓存结构:{url: SnapshotInfo_dict, ...} - """ - import json - import os - - cache_file = os.path.join(_CACHE_DIR, _CACHE_FILE) - if not os.path.exists(cache_file): - return {} - - try: - with open(cache_file, encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - -def _save_cache(cache: dict[str, dict]): - """ - 保存快照缓存。 - - 缓存结构:{url: SnapshotInfo_dict, ...} - """ - import json - import os - - os.makedirs(_CACHE_DIR, exist_ok=True) - cache_file = os.path.join(_CACHE_DIR, _CACHE_FILE) - - with open(cache_file, "w", encoding="utf-8") as f: - json.dump(cache, f, ensure_ascii=False, indent=2) - - -def _snapshot_from_cache(url: str, cache: dict[str, dict]) -> SnapshotInfo | None: - """ - 从缓存中恢复 SnapshotInfo。 - - 如果 URL 在缓存中且数据有效,返回 SnapshotInfo;否则返回 None。 - """ - if url not in cache: - return None - - try: - data = cache[url] - return SnapshotInfo(**data) - except Exception: - return None - - -def _snapshot_to_cache(url: str, snap: SnapshotInfo, cache: dict[str, dict]): - """ - 将 SnapshotInfo 保存到缓存。 - """ - from dataclasses import asdict - - cache[url] = asdict(snap) - - if __name__ == "__main__": main() diff --git a/scripts/python/utils/__init__.py b/scripts/python/utils/__init__.py index e69de29bb..b46b29317 100644 --- a/scripts/python/utils/__init__.py +++ b/scripts/python/utils/__init__.py @@ -0,0 +1,23 @@ +""" +工具模块 + +提供通用工具函数、数据结构和缓存管理。 +""" + +from utils.cache import SnapshotCache, get_ci_cache, get_debug_cache +from utils.common import SNAPSHOT_KINDS, build_full_text_from_links, merge_links_dedup +from utils.models import CheckReport, LinkCheckResult, LinkInfo, NetworkResult, SnapshotInfo + +__all__ = [ + "SNAPSHOT_KINDS", + "merge_links_dedup", + "build_full_text_from_links", + "SnapshotCache", + "get_ci_cache", + "get_debug_cache", + "LinkInfo", + "SnapshotInfo", + "NetworkResult", + "CheckReport", + "LinkCheckResult", +] diff --git a/scripts/python/utils/cache.py b/scripts/python/utils/cache.py new file mode 100644 index 000000000..2e8b60c41 --- /dev/null +++ b/scripts/python/utils/cache.py @@ -0,0 +1,131 @@ +""" +快照缓存管理模块 + +提供统一的缓存管理功能,支持不同场景(CI/本地调试)使用不同的缓存目录。 +消除 check_issue.py 和 debug_sim.py 中的重复缓存代码。 +""" + +import json +from dataclasses import asdict +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from utils.models import SnapshotInfo + +# ── 缓存类 ── + + +class SnapshotCache: + """ + 快照缓存管理器 + + 支持从文件加载/保存缓存,以及内存中的缓存操作。 + """ + + def __init__(self, cache_dir: str | Path, cache_file: str = "snapshots.json"): + """ + 初始化缓存管理器。 + + 参数: + cache_dir: 缓存目录路径 + cache_file: 缓存文件名 + """ + self.cache_dir = Path(cache_dir) + self.cache_file = cache_file + self._cache: dict[str, dict] = {} + self._updated = False + + def load(self) -> dict[str, dict]: + """ + 从文件加载缓存。 + + 返回: + 缓存字典,格式为 {url: SnapshotInfo_dict, ...} + """ + cache_path = self.cache_dir / self.cache_file + if not cache_path.exists(): + self._cache = {} + return self._cache + + try: + with open(cache_path, encoding="utf-8") as f: + self._cache = json.load(f) + except Exception: + self._cache = {} + + return self._cache + + def save(self): + """ + 保存缓存到文件。 + + 仅当有更新时才写入磁盘。 + """ + if not self._updated: + return + + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_path = self.cache_dir / self.cache_file + + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(self._cache, f, ensure_ascii=False, indent=2) + + def get(self, url: str, snapshot_cls: type | None = None) -> "SnapshotInfo | None": + """ + 从缓存中获取快照。 + + 参数: + url: 快照 URL + snapshot_cls: SnapshotInfo 类(用于反序列化) + + 返回: + SnapshotInfo 对象,或 None(未命中) + """ + if url not in self._cache: + return None + + if snapshot_cls is None: + from utils.models import SnapshotInfo + + snapshot_cls = SnapshotInfo + + try: + return snapshot_cls(**self._cache[url]) + except Exception: + return None + + def set(self, url: str, snap: "SnapshotInfo"): + """ + 将快照保存到缓存。 + + 参数: + url: 快照 URL + snap: SnapshotInfo 对象 + """ + self._cache[url] = asdict(snap) + self._updated = True + + @property + def updated(self) -> bool: + """缓存是否有更新""" + return self._updated + + +# ── 便捷函数 ── + +# CI 环境缓存目录 +CI_CACHE_DIR = "/tmp/snapshot_cache" + +# 本地调试缓存目录 +DEBUG_CACHE_DIR = Path.home() / ".cache" / "gkd_debug" + + +def get_ci_cache() -> SnapshotCache: + """获取 CI 环境的缓存实例""" + return SnapshotCache(CI_CACHE_DIR) + + +def get_debug_cache() -> SnapshotCache: + """获取本地调试的缓存实例""" + return SnapshotCache(DEBUG_CACHE_DIR) diff --git a/scripts/python/utils/common.py b/scripts/python/utils/common.py index 873641bc0..9d3974707 100644 --- a/scripts/python/utils/common.py +++ b/scripts/python/utils/common.py @@ -6,6 +6,15 @@ """ import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +# ── 常量 ── + +# 快照相关链接类型集合 +SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} # ── URL 处理函数 ── From 06326cc44fb12c67240723237a68604b13f13fa8 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 15:55:11 +0800 Subject: [PATCH 81/90] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=AD=A5=E9=87=8D=E6=9E=84=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 scripts/python/README.md: - 目录结构树(新增 base.py, issue_checker.py, cache.py) - 模块职责表格(更新 api/ 和 utils/ 描述) - 使用方式示例(使用 IssueChecker 替代 LinkChecker) - 依赖关系图(反映新的模块依赖) - 新增架构优势说明 - 更新 .claude/rules/ci-cd.md: - Python 模块结构树(同步最新目录结构) - Python 脚本说明(新增 base.py, issue_checker.py, cache.py 描述) --- .claude/rules/ci-cd.md | 16 ++++++--- scripts/python/README.md | 76 +++++++++++++++++++++++++++++++--------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/.claude/rules/ci-cd.md b/.claude/rules/ci-cd.md index 30e4eb6ff..657bd27ce 100644 --- a/.claude/rules/ci-cd.md +++ b/.claude/rules/ci-cd.md @@ -302,17 +302,23 @@ device_model · Android release · GKD version ``` scripts/python/ ├── core/ # 核心功能层 + │ ├── __init__.py │ ├── extractor.py # 链接提取与分类 - │ ├── checker.py # 网络检查 + │ ├── checker.py # 网络检查(使用 httpx) │ ├── converter.py # 链接转换 │ └── snapshot_parser.py # 快照解析 ├── utils/ # 工具模块层 + │ ├── __init__.py │ ├── models.py # 数据结构定义 - │ ├── common.py # 通用工具函数 + │ ├── common.py # 通用工具函数 + SNAPSHOT_KINDS 常量 + │ ├── cache.py # 缓存管理 │ └── utils.py # GITHUB_OUTPUT 工具 ├── api/ # 高层 API 层 - │ └── link_checker.py # 可复用的链接检查器 + │ ├── __init__.py + │ ├── base.py # URLChecker 基类(支持多场景扩展) + │ └── issue_checker.py # Issue 场景检查器 ├── entry/ # 入口脚本层 + │ ├── __init__.py │ └── check_issue.py # Issue 场景主入口 ├── tests/ # 测试层 │ ├── test_extractor.py # extractor.py 单元测试 @@ -343,6 +349,8 @@ scripts/python/ - `core/snapshot_parser.py` — 下载并解析快照 zip 文件 - `formatter.py` — 生成 Bot 评论的 Markdown 内容 - `core/converter.py` — GitHub 附件 → GKD 代理链接转换 -- `api/link_checker.py` — 可复用的链接检查 API(可在其他 CI 中使用) +- `api/base.py` — URLChecker 基类,支持多场景扩展(Issue/PR/Commit) +- `api/issue_checker.py` — Issue 场景检查器,封装完整的 Issue 分析流程 +- `utils/cache.py` — 快照缓存管理,支持 CI 和本地调试两种模式 详细说明见 `scripts/python/README.md` diff --git a/scripts/python/README.md b/scripts/python/README.md index 01e79dd83..070bd45b8 100644 --- a/scripts/python/README.md +++ b/scripts/python/README.md @@ -7,17 +7,23 @@ ``` scripts/python/ ├── core/ # 核心功能层 +│ ├── __init__.py │ ├── extractor.py # 链接提取与分类 -│ ├── checker.py # 网络检查 +│ ├── checker.py # 网络检查(使用 httpx) │ ├── converter.py # 链接转换 │ └── snapshot_parser.py # 快照解析 ├── utils/ # 工具模块层 +│ ├── __init__.py │ ├── models.py # 数据结构定义 -│ ├── common.py # 通用工具函数 +│ ├── common.py # 通用工具函数 + SNAPSHOT_KINDS 常量 +│ ├── cache.py # 缓存管理 │ └── utils.py # GITHUB_OUTPUT 工具 ├── api/ # 高层 API 层 -│ └── link_checker.py # 可复用的链接检查器 +│ ├── __init__.py +│ ├── base.py # URLChecker 基类(支持多场景扩展) +│ └── issue_checker.py # Issue 场景检查器 ├── entry/ # 入口脚本层 +│ ├── __init__.py │ └── check_issue.py # Issue 场景主入口 ├── tests/ # 测试层 │ ├── test_extractor.py # extractor.py 单元测试 @@ -38,8 +44,8 @@ scripts/python/ | 文件 | 职责 | 主要函数 | |------|------|---------| -| `extractor.py` | 从文本提取链接 | `extract_links(text)` | -| `checker.py` | 检查链接可访问性 | `check_network_links(url)`, `gkd_to_gh_attachment_url(url)` | +| `extractor.py` | 从文本提取链接 | `extract_links(text)`, `extract_links_from_bot_comment(text)` | +| `checker.py` | 检查链接可访问性 | `check_network_links(url)`, `gkd_to_gh_attachment_url(url)`, `check_urls_concurrent(urls)` | | `converter.py` | 链接格式转换 | `convert_github_attachments(links)` | | `snapshot_parser.py` | 下载解析快照zip | `download_and_parse(url)` | @@ -48,14 +54,16 @@ scripts/python/ | 文件 | 职责 | 主要函数/类 | |------|------|------------| | `models.py` | 数据结构定义 | `LinkInfo`, `NetworkResult`, `SnapshotInfo`, `CheckReport` | -| `common.py` | 通用工具函数 | `extract_filename()`, `short_activity_name()` | +| `common.py` | 通用工具函数 | `SNAPSHOT_KINDS`, `extract_filename()`, `short_activity_name()`, `merge_links_dedup()` | +| `cache.py` | 缓存管理 | `SnapshotCache`, `get_ci_cache()`, `get_debug_cache()` | | `utils.py` | 工具函数 | `write_output()` | ### api/ - 高层 API 层 | 文件 | 职责 | 主要函数/类 | |------|------|------------| -| `link_checker.py` | 可复用的链接检查器 | `LinkChecker` 类, `check_links_in_text()` | +| `base.py` | URL 检查器基类 | `URLChecker` 抽象类(支持继承扩展) | +| `issue_checker.py` | Issue 场景检查器 | `IssueChecker` 类, `check_issue()` 便捷函数 | ### entry/ - 入口脚本层 @@ -76,21 +84,47 @@ scripts/python/ ## 使用方式 -### 1. 在其他 CI 中复用(推荐) +### 1. Issue 场景专用(推荐) ```python -from api.link_checker import LinkChecker, check_links_in_text +from api import IssueChecker, check_issue # 方式1:使用类 -checker = LinkChecker(timeout=20) -report = checker.extract_and_check(text) -print(f"检查完成: {report.ok_count} 成功, {report.fail_count} 失败") +checker = IssueChecker(timeout=20) +result = checker.analyze( + text=issue_body, + comment_body=comment_body, + issue_action="opened", + issue_user="testuser", +) +print(f"检查完成: has_snapshot={result['has_snapshot']}") # 方式2:使用便捷函数 -report = check_links_in_text(text) +result = check_issue( + body=issue_body, + issue_action="opened", + issue_user="testuser", +) ``` -### 2. Issue 场景专用 +### 2. 扩展新场景(面向对象) + +```python +from api.base import URLChecker +from utils.models import LinkInfo + +class PRChecker(URLChecker): + """PR 场景检查器""" + + def analyze(self, text: str, **kwargs) -> dict: + # PR 特定的分析逻辑 + links = self.extract_links(text) + net_result = self.check_all_links(links) + # ... + return result +``` + +### 3. Issue 场景命令行 ```bash cd scripts/python @@ -190,6 +224,7 @@ python tests/verify.py ``` utils/models.py (无依赖) utils/common.py (无依赖) +utils/cache.py (依赖 utils/models.py) utils/utils.py (无依赖) ↓ core/extractor.py → utils/models.py @@ -198,7 +233,16 @@ core/converter.py → utils/models.py, utils/common.py core/snapshot_parser.py → utils/models.py formatter.py → utils/models.py, utils/common.py ↓ -api/link_checker.py → utils/models.py, core/*.py +api/base.py → utils/models.py, utils/cache.py, core/*.py +api/issue_checker.py → api/base.py, utils/*.py, core/*.py, formatter.py ↓ -entry/check_issue.py → utils/*.py, core/*.py, formatter.py +entry/check_issue.py → api/issue_checker.py, utils/utils.py +debug_sim.py → api/issue_checker.py, utils/cache.py ``` + +## 架构优势 + +1. **面向对象**:`URLChecker` 基类支持继承扩展,可轻松添加 PR/Commit 场景 +2. **消除重复**:缓存管理、网络检查逻辑统一在基类中 +3. **易于测试**:每个模块职责单一,可独立测试 +4. **并发支持**:`check_urls_concurrent()` 支持批量并发检查 From 7b06c22650affd1ac6a4585f72026078f746fd78 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 15:58:53 +0800 Subject: [PATCH 82/90] =?UTF-8?q?perf:=20CLAUDE=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c2c256e61..34d54657d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,10 @@ pnpm run format # Prettier 格式化 - ❌ 不要修改 `.env` 文件 - ❌ 不要绕过 `pnpm run check` 直接提交 +## 注意事项 + +- 当代码架构发生中大幅度改变时(影响文档指导准确性时),需要更新对应的`.md`文件。 + ## 目录结构 ``` From 82916159f0d8927927b328e7c7be6c9820808b0f Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Wed, 15 Jul 2026 16:12:19 +0800 Subject: [PATCH 83/90] =?UTF-8?q?fix(ci):=20=E6=B7=BB=E5=8A=A0=20httpx=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=AE=89=E8=A3=85=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在配置 Python 环境后添加 pip install httpx 步骤 - 修复 ModuleNotFoundError: No module named 'httpx' 错误 - 恢复 core/checker.py 使用 httpx 的实现 --- .github/workflows/issue_content_check.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index e825d9f7b..9eb2ee38e 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -72,6 +72,12 @@ jobs: with: python-version: '3.12' + - name: 安装 Python 依赖 + if: >- + github.event_name != 'issue_comment' || + steps.pre-check.outputs.needs_analysis != 'false' + run: pip install httpx + - name: 创建工作流所需标签 if: >- github.event_name != 'issue_comment' || From d7781fdf34c9800d51138f79cca668802d9fd222 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 16 Jul 2026 08:06:43 +0800 Subject: [PATCH 84/90] =?UTF-8?q?docs:=20=E6=A0=B9=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=BF=9B=E8=A1=8C=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/snapshot.md | 285 +++++++++++++++++++++++++++++-------------- 1 file changed, 191 insertions(+), 94 deletions(-) diff --git a/docs/api/snapshot.md b/docs/api/snapshot.md index 93c32f94b..3447fa9ed 100644 --- a/docs/api/snapshot.md +++ b/docs/api/snapshot.md @@ -1,5 +1,18 @@ # GKD 快照数据 API 文档 +## 快照版本历史 + +| GKD 版本 | 快照格式变化 | 主要影响 | +| --------------- | ------------------------------------- | -------------------- | +| 2023-10-16 之前 | 无 quickFind 字段 | 早期快照 | +| 2023-10-16 | 添加 `quickFind` 字段 | 初步支持快速查询 | +| 2023-11-14 | `quickFind` 分离为 `idQf` 和 `textQf` | 更精细的快速查询支持 | +| 2023-11-17 | 移除 idQf/textQf 的相互依赖 | 两者完全独立 | +| 约 2024-01 | 移除 `quickFind` 字段 | 仅保留 idQf/textQf | +| 2025-11-02 | 修复 appInfo 获取逻辑 | 提高兼容性 | + +--- + ## 1. 概述 GKD(自动跳过广告工具)在运行过程中会通过无障碍服务捕获当前界面的视图树快照(Snapshot),并以 JSON 格式存储。该快照包含了应用信息、设备信息、GKD 自身状态以及完整的视图节点树,可用于离线分析、规则编写与调试。 @@ -12,51 +25,46 @@ GKD(自动跳过广告工具)在运行过程中会通过无障碍服务捕 快照 JSON 的顶层是一个对象,包含以下字段: -| 字段 | 类型 | 必选 | 说明 | -| ---------------- | ---------- | ---- | ------------------------------------------------------------ | -| `id` | number | 是 | 快照的唯一标识(通常为时间戳) | -| `appId` | string | 是 | 目标应用的包名 | -| `activityId` | string | 是 | 当前界面的 Activity 完整类名 | -| `screenHeight` | int | 是 | 屏幕高度(像素) | -| `screenWidth` | int | 是 | 屏幕宽度(像素) | -| `isLandscape` | boolean | 是 | 是否为横屏 | -| `appInfo` | object | 否 | 目标应用的详细信息(见 2.1),与顶层 `appName` 等互斥 | -| `appName` | string | 否 | 目标应用的显示名称(精简模式) | -| `appVersionCode` | int/string | 否 | 目标应用的版本号(精简模式) | -| `appVersionName` | string | 否 | 目标应用的版本名称(精简模式) | -| `gkdAppInfo` | object | 否 | GKD 自身的详细信息(见 2.2),与顶层 `gkdVersionCode` 等互斥 | -| `gkdVersionCode` | int | 否 | GKD 的版本号(精简模式) | -| `gkdVersionName` | string | 否 | GKD 的版本名称(精简模式) | -| `device` | object | 是 | 设备信息(见 2.3) | -| `nodes` | array | 是 | 视图节点数组,每个元素为一个节点对象(见第 3 节) | - -### 2.1 `appInfo` 对象(完整模式) - -| 字段 | 类型 | 说明 | -| ------------- | ------- | ----------------------- | -| `id` | string | 包名(同 `appId`) | -| `name` | string | 应用名称 | -| `versionCode` | int | 版本号 | -| `versionName` | string | 版本名称 | -| `isSystem` | boolean | 是否为系统应用 | -| `mtime` | number | 最后修改时间戳(毫秒) | -| `hidden` | boolean | 是否隐藏 | -| `enabled` | boolean | 是否启用 | -| `userId` | int | 用户 ID(0 表示主用户) | - -### 2.2 `gkdAppInfo` 对象(完整模式) - -| 字段 | 类型 | 说明 | -| ------------- | ------- | -------------------------- | -| `id` | string | GKD 包名(`li.songe.gkd`) | -| `name` | string | GKD 名称 | -| `versionCode` | int | GKD 版本号 | -| `versionName` | string | GKD 版本名称 | -| `isSystem` | boolean | 是否系统应用 | -| `mtime` | number | 最后修改时间戳 | -| `hidden` | boolean | 是否隐藏 | -| `enabled` | boolean | 是否启用 | -| `userId` | int | 用户 ID | +| 字段 | 类型 | 必选 | 说明 | +| -------------- | ----------- | ---- | ------------------------------------------------- | +| `id` | number | 是 | 快照的唯一标识(通常为时间戳) | +| `appId` | string | 是 | 目标应用的包名 | +| `activityId` | string/null | 否 | 当前界面的 Activity 完整类名,可能为 null | +| `screenHeight` | int | 是 | 屏幕高度(像素) | +| `screenWidth` | int | 是 | 屏幕宽度(像素) | +| `isLandscape` | boolean | 是 | 是否为横屏 | +| `appInfo` | object/null | 否 | 目标应用的详细信息(见 2.1),可能为 null | +| `gkdAppInfo` | object/null | 否 | GKD 自身的详细信息(见 2.2),可能为 null | +| `device` | object | 是 | 设备信息(见 2.3) | +| `nodes` | array | 是 | 视图节点数组,每个元素为一个节点对象(见第 3 节) | + +> **旧版兼容字段**:旧版 GKD 生成的快照不包含 `appInfo` / `gkdAppInfo`,而是使用以下字段。解析器应优先读取 `appInfo` / `gkdAppInfo`,若缺失则回退至这些字段: +> +> | 字段 | 类型 | 说明 | +> | ---------------- | ------ | ------------------------------------------------------------ | +> | `appName` | string | 目标应用的显示名称 → 对应 `appInfo.name` | +> | `appVersionCode` | int | 目标应用的版本号 → 对应 `appInfo.versionCode` | +> | `appVersionName` | string | 目标应用的版本名称 → 对应 `appInfo.versionName` | +> | `gkdVersionCode` | int | GKD 的版本号 → 对应 `gkdAppInfo.versionCode`(旧版位于 `device` 对象内) | +> | `gkdVersionName` | string | GKD 的版本名称 → 对应 `gkdAppInfo.versionName`(旧版位于 `device` 对象内) | + +### 2.1 `appInfo` 对象 + +| 字段 | 类型 | 说明 | +| ------------- | ----------- | ----------------------------------- | +| `id` | string | 包名(同 `appId`) | +| `name` | string | 应用名称 | +| `versionCode` | int | 版本号 | +| `versionName` | string/null | 版本名称,可能为 null | +| `isSystem` | boolean | 是否为系统应用 | +| `mtime` | number | 最后修改时间戳(毫秒) | +| `hidden` | boolean | 是否隐藏 | +| `enabled` | boolean | 应用是否启用(来自 PackageManager) | +| `userId` | int | 用户 ID(来自 MultiUserManager) | + +### 2.2 `gkdAppInfo` 对象 + +结构与 `appInfo` 相同(见 2.1),用于描述 GKD 自身信息,其中 `id` 固定为 `li.songe.gkd`。 ### 2.3 `device` 对象 @@ -75,13 +83,13 @@ GKD(自动跳过广告工具)在运行过程中会通过无障碍服务捕 每个节点代表视图树中的一个视图(View)或视图组(ViewGroup)。 -| 字段 | 类型 | 必选 | 说明 | -| -------- | ------------ | ---- | ------------------------------------------------------------------- | -| `id` | int | 是 | 节点在当前数组中的唯一自增标识,从 0 开始 | -| `pid` | int | 是 | 父节点的 `id`,根节点为 `-1` | -| `idQf` | boolean/null | 是 | **合格标志**:`attr.id` 是否稳定且可用于快速查询(见第 5 节) | -| `textQf` | boolean/null | 是 | **合格标志**:`attr.text` 是否静态稳定且可用于快速查询(见第 5 节) | -| `attr` | object | 是 | 节点的详细属性(见第 4 节) | +| 字段 | 类型 | 必选 | 说明 | +| -------- | -------- | ---- | ------------------------------------------------------------------- | +| `id` | int | 是 | 节点在当前数组中的唯一自增标识,从 0 开始 | +| `pid` | int | 是 | 父节点的 `id`,根节点为 `-1` | +| `idQf` | boolean? | 是 | **合格标志**:`attr.id` 是否稳定且可用于快速查询(见第 5 节) | +| `textQf` | boolean? | 是 | **合格标志**:`attr.text` 是否静态稳定且可用于快速查询(见第 5 节) | +| `attr` | object | 是 | 节点的详细属性(见第 4 节) | > **注意**:历史快照中可能缺少 `idQf` 或 `textQf` 字段(即 `undefined`),解析时应视为 `null`。 @@ -91,29 +99,29 @@ GKD(自动跳过广告工具)在运行过程中会通过无障碍服务捕 描述视图的具体布局、内容及交互属性。 -| 字段 | 类型 | 说明 | -| --------------- | ----------- | ------------------------------------------ | -| `id` | string/null | 视图的资源 ID(如 `android:id/content`) | -| `vid` | string/null | 视图的资源名称(ID 的简写) | -| `name` | string | 视图的类名(如 `android.widget.TextView`) | -| `text` | string/null | 视图显示的文本内容 | -| `desc` | string/null | 内容描述(无障碍描述) | -| `clickable` | boolean | 是否可点击 | -| `focusable` | boolean | 是否可获得焦点 | -| `checkable` | boolean | 是否可勾选 | -| `checked` | boolean | 是否已勾选 | -| `editable` | boolean | 是否可编辑 | -| `longClickable` | boolean | 是否可长按 | -| `visibleToUser` | boolean | 是否对用户可见 | -| `left` | int | 视图左边缘坐标(像素) | -| `top` | int | 视图上边缘坐标 | -| `right` | int | 视图右边缘坐标 | -| `bottom` | int | 视图下边缘坐标 | -| `width` | int | 视图宽度(`right - left`) | -| `height` | int | 视图高度(`bottom - top`) | -| `childCount` | int | 子节点数量(仅视图组有效) | -| `index` | int | 在父节点中的位置索引 | -| `depth` | int | 在视图树中的深度(根节点为 0) | +| 字段 | 类型 | 说明 | +| --------------- | ------------ | ------------------------------------------ | +| `id` | string/null | 视图的资源 ID(如 `android:id/content`) | +| `vid` | string/null | 视图的资源名称(ID 的简写) | +| `name` | string/null | 视图的类名(如 `android.widget.TextView`) | +| `text` | string/null | 视图显示的文本内容 | +| `desc` | string/null | 内容描述(无障碍描述) | +| `clickable` | boolean | 是否可点击 | +| `focusable` | boolean | 是否可获得焦点 | +| `checkable` | boolean | 是否可勾选 | +| `checked` | boolean/null | 是否已勾选,可能为 null | +| `editable` | boolean | 是否可编辑 | +| `longClickable` | boolean | 是否可长按 | +| `visibleToUser` | boolean | 是否对用户可见 | +| `left` | int | 视图左边缘坐标(像素) | +| `top` | int | 视图上边缘坐标 | +| `right` | int | 视图右边缘坐标 | +| `bottom` | int | 视图下边缘坐标 | +| `width` | int | 视图宽度(`right - left`) | +| `height` | int | 视图高度(`bottom - top`) | +| `childCount` | int | 子节点数量(仅视图组有效) | +| `index` | int | 在父节点中的位置索引 | +| `depth` | int | 在视图树中的深度(根节点为 0) | --- @@ -128,29 +136,86 @@ GKD(自动跳过广告工具)在运行过程中会通过无障碍服务捕 GKD 在匹配规则时,如果选择器使用了 `[vid="..."]` 或 `[text="..."]`,且对应节点的 `idQf` 或 `textQf` 为 `true`,则会调用 Android 系统的快速查找 API,**避免手动遍历整个视图树**,极大提升匹配效率。 -- **适用条件**:节点必须在快照面板中被标记为“可快速查找”(即 `idQf === true` 或 `textQf === true`),否则快速查询 API 可能返回空或错误结果。 +- **适用条件**:节点必须在快照面板中被标记为"可快速查找"(即 `idQf === true` 或 `textQf === true`),否则快速查询 API 可能返回空或错误结果。 - **选择器示例**: - `[vid="com.example:id/confirm_button"]` → 依赖 `idQf` - `[text="确定"]` → 依赖 `textQf` +> **注意**:`textQf` 仅在 `[text="..."]` 精确匹配时有效。`text*="..."` / `text~="..."` / `text$="..."` 等通配符匹配无法使用快速查询,需要手动遍历视图树。 + ### 5.3 匹配规则 在编写或解析规则时,**必须遵守以下约束**: -| 条件 | 行为 | -| ---------------------------- | ----------------------------------------------------------------------- | -| `idQf === true` | 可以安全地使用 `attr.id` 进行精确匹配,并可启用快速查询 | -| `idQf === false` 或 `null` | **不应**使用 `attr.id` 作为匹配条件(ID 可能动态变化或不可靠) | -| `textQf === true` | 可以安全地使用 `attr.text` 进行完全匹配,并可启用快速查询 | -| `textQf === false` 或 `null` | **不应**使用 `attr.text` 作为固定文本匹配(例如倒计时“03:59:45”应忽略) | +| 条件 | 行为 | +| ---------------------------- | ------------------------------------------------------------------------- | +| `idQf === true` | 可以安全地使用 `attr.id` 进行精确匹配,并可启用快速查询 | +| `idQf === false` 或 `null` | **不建议**使用 `attr.id` 作为匹配条件(ID 可能动态变化或不可靠) | +| `textQf === true` | 可以安全地使用 `attr.text` 进行完全匹配,并可启用快速查询 | +| `textQf === false` 或 `null` | **不建议**使用 `attr.text` 作为固定文本匹配(例如倒计时"03:59:45"应忽略) | > **解析器实现要求**:在匹配节点前,必须检查对应的 QF 标志。仅当标志为 `true` 时,才将该属性纳入匹配条件。 +### 5.4 传播机制 + +GKD 在生成快照时,`idQf` / `textQf` 并非逐节点独立计算,而是通过**传播机制**批量赋值: + +1. **计算顺序**: + - 第一轮:从叶子节点向根节点反向遍历,只处理叶子节点 + - 第二轮:再次反向遍历,处理非叶子节点 +2. **兄弟传播**:若某节点的 `idQf` 被确定为值,则其兄弟节点中未初始化的会被赋相同值(`textQf` 同理)。 +3. **祖先传播**:若某节点的 `idQf` 为 `true`,则向所有未初始化的祖先节点传播 `idQf = true`,并同步传播至祖先的未初始化兄弟节点(叔伯节点)。 +4. **子树传播**:若某节点的 `idQf` 为 `false`,则向其**整个子树**传播 `idQf = false`。 +5. **等价传播**:若存在一个节点同时满足 `idQf === true && textQf === true`,则标记全局 `idTextQf = true`。后续传播中: + - 传播 `idQf` 时,会同步传播 `textQf` 至兄弟节点和祖先的兄弟节点 + - 传播 `textQf` 时,会同步传播 `idQf` 至兄弟节点和祖先的兄弟节点 + +> **实际影响**:快照中经常出现整棵子树的 `idQf` / `textQf` 值完全相同,这是传播机制的结果,而非每个节点独立验证。 + --- ## 6. 示例 -### 6.1 快照根对象(精简模式) +### 6.1 快照根对象(当前版本) + +```json +{ + "id": 1711547793221, + "appId": "com.miHoYo.cloudgames.ys", + "activityId": "com.mihoyo.cloudgame.main.MiHoYoCloudMainActivity", + "screenHeight": 1080, + "screenWidth": 2400, + "isLandscape": true, + "appInfo": { + "id": "com.miHoYo.cloudgames.ys", + "name": "云·原神", + "versionCode": 400000014, + "versionName": "4.5.0", + "isSystem": false, + "mtime": 1711547793000, + "hidden": false, + "enabled": true, + "userId": 10 + }, + "gkdAppInfo": { + "id": "li.songe.gkd", + "name": "GKD", + "versionCode": 27, + "versionName": "1.7.2", + "isSystem": false, + "mtime": 1711547793000, + "hidden": false, + "enabled": true, + "userId": 10 + }, + "device": { ... }, + "nodes": [ ... ] +} +``` + +### 6.1b 快照根对象(旧版格式) + +> 旧版 GKD 生成的快照不包含 `appInfo` / `gkdAppInfo`,应用信息以扁平字段形式存储,GKD 版本信息位于 `device` 对象内: ```json { @@ -163,9 +228,16 @@ GKD 在匹配规则时,如果选择器使用了 `[vid="..."]` 或 `[text="..." "screenHeight": 1080, "screenWidth": 2400, "isLandscape": true, - "gkdVersionCode": 27, - "gkdVersionName": "1.7.2", - "device": { ... }, + "device": { + "device": "PD2445", + "model": "V2330A", + "manufacturer": "vivo", + "brand": "vivo", + "sdkInt": 34, + "release": "14", + "gkdVersionCode": 27, + "gkdVersionName": "1.7.2" + }, "nodes": [ ... ] } ``` @@ -221,9 +293,9 @@ GKD 在匹配规则时,如果选择器使用了 `[vid="..."]` 或 `[text="..." ## 7. 错误处理与兼容性 ### 7.1 缺失字段 -- 若 `idQf` 或 `textQf` 缺失(`undefined`),解析时应视为 `null`,按“不合格”处理。 -- 若 `appInfo` 缺失,应回退读取 `appName`、`appVersionCode` 等顶层字段。 -- 若 `gkdAppInfo` 缺失,应回退读取 `gkdVersionCode`、`gkdVersionName`。 +- 若 `idQf` 或 `textQf` 缺失(`undefined`),解析时应视为 `null`,按"不合格"处理。 +- **应用信息解析策略**:优先读取 `appInfo` 对象;若 `appInfo` 为 `null` 或缺失,回退读取顶层 `appName`、`appVersionCode`、`appVersionName`。 +- **GKD 信息解析策略**:优先读取 `gkdAppInfo` 对象;若 `gkdAppInfo` 为 `null` 或缺失,回退读取 `device.gkdVersionCode`、`device.gkdVersionName`(旧版位于 `device` 对象内,而非顶层)。 ### 7.2 树结构异常 - **孤儿节点**:`pid` 指向不存在的 `id` → 将该节点视为根节点。 @@ -234,7 +306,33 @@ GKD 在匹配规则时,如果选择器使用了 `[vid="..."]` 或 `[text="..." - 即使 `idQf === true`,系统 API 也可能因节点未附加到窗口而返回空。解析器应实现回退策略:快速查询失败后,自动切换为手动遍历。 ### 7.4 版本兼容 -- 旧版 GKD 生成的快照可能不含 `idQf` / `textQf`,此时默认所有节点的这两个标志均为 `null`,即**无法使用快速查询**,需完全遍历。 + +#### 7.4.1 节点字段差异 + +| 字段 | 当前版本 | 旧版快照 | 处理方式 | +| ----------- | ---------- | ---------------------- | ---------------------------------------------------------------------------- | +| `idQf` | `boolean?` | 可能缺失 | 缺失时视为 `null`,不可快速查询 | +| `textQf` | `boolean?` | 可能缺失 | 缺失时视为 `null`,不可快速查询 | +| `quickFind` | 不存在 | `boolean?`(历史字段) | 2023-10-16 引入,后分离为 idQf/textQf,现已完全移除。inspect-plus 兼容此字段 | + +#### 7.4.2 属性字段差异 + +| 字段 | 当前版本 | 旧版快照 | 处理方式 | +| ------------- | --------- | --------- | -------------------------------- | +| `clickable` | `boolean` | 可能缺失 | 缺失时回退读取 `isClickable` | +| `isClickable` | 不存在 | `boolean` | 等价于当前的 `clickable` | +| `textLen` | 不存在 | `number?` | 文本长度,当前版本已移除,可忽略 | +| `descLen` | 不存在 | `number?` | 描述长度,当前版本已移除,可忽略 | +| 布尔属性 | 非空 | 可能缺失 | 缺失时视为 `false` | + +#### 7.4.3 根对象字段差异 + +| 字段 | 当前版本 | 旧版快照 | 处理方式 | +| ------------- | ---------- | --------- | ---------------------------------------------- | +| `appInfo` | `AppInfo?` | 不存在 | 缺失时回退读取 `appName` / `appVersionCode` 等 | +| `gkdAppInfo` | `AppInfo?` | 不存在 | 缺失时回退读取 `device.gkdVersionCode` 等 | +| `appName` 等 | 不存在 | 顶层字段 | 被 `appInfo` 取代,见第 2 节兼容字段表 | +| `gkdVersion*` | 不存在 | device 内 | 被 `gkdAppInfo` 取代,见第 2 节兼容字段表 | --- @@ -245,7 +343,6 @@ GKD 在匹配规则时,如果选择器使用了 `[vid="..."]` 或 `[text="..." --- -*文档版本:1.0* -*最后更新:2026-04-06* -*author:DeepSeek* -'内容由AI生成,请仔细甄别' \ No newline at end of file +*文档版本:1.1* +*最后更新:2026-07-16* +*基于 GKD 源码核实修正* \ No newline at end of file From 9e07e46c42bf43a7c34b23d11955fd8e13a31756 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 16 Jul 2026 22:44:17 +0800 Subject: [PATCH 85/90] =?UTF-8?q?perf:=20=E6=8A=98=E5=8F=A0=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/formatter.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index 87a1e374c..b817f1217 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -173,8 +173,8 @@ def _render_app_section( # App 副标题:设备 + Android + GKD(取第一个快照的设备信息) first = snapshots[0] subtitle_parts = [] - if first.device_model: - subtitle_parts.append(first.device_model) + # if first.device_model: + # subtitle_parts.append(first.device_model) if first.device_release: subtitle_parts.append(f"Android {first.device_release}") if first.gkd_version_name: @@ -208,13 +208,13 @@ def _render_activity_line(lines: list[str], snap: SnapshotInfo, links: list[tupl act_display = short_activity_name(snap.activity_id) # 统计信息行 - stats = ( - f"快查 ID:{snap.id_qf_count} Text:{snap.text_qf_count}" - f" · 深度{snap.max_depth}" - f" · 可点击{snap.clickable_nodes}" - f" · {snap.total_nodes}节点" - ) - lines.append(f"**{act_display}** — {stats}") + # stats = ( + # f"快查 ID:{snap.id_qf_count} Text:{snap.text_qf_count}" + # f" · 深度{snap.max_depth}" + # f" · 可点击{snap.clickable_nodes}" + # f" · {snap.total_nodes}节点" + # ) + lines.append(f"**{act_display}**") # 链接行(该 Activity 的所有快照链接) if links: @@ -231,7 +231,7 @@ def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: 渲染折叠区内容。 包含: - - 按 App 分组的详细信息表(可见/分辨率/方向/appVersionCode/GKD/userId) + - 按 App 分组的详细信息表(可见/分辨率/方向/APP版本代码/快查ID&Text/深度/可点击/节点数/GKD/userId) - 设备信息表(去重) """ if not snapshots: @@ -248,8 +248,12 @@ def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: for app_key, app_snaps in app_groups.items(): lines.append(f"**{app_key}**") lines.append("") - lines.append("| Activity | 可见 | 分辨率 | 方向 | appVersionCode | GKD | userId |") - lines.append("|----------|------|--------|------|----------------|-----|--------|") + lines.append( + "| Activity | 可见 | 分辨率 | 方向 | APP版本代码 | 快查ID/Text | 深度 | 可点击 | 节点数 | GKD | userId |" + ) + lines.append( + "|----------|------|--------|------|-----------|-------------|------|-------|-------|-----|--------|" + ) for snap in app_snaps: orientation = "横屏" if snap.is_landscape else "竖屏" resolution = f"{snap.screen_width}×{snap.screen_height}" @@ -257,7 +261,8 @@ def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: act_display = short_activity_name(snap.activity_id) lines.append( f"| {act_display} | {snap.visible_nodes} | {resolution} | {orientation} " - f"| {snap.app_version_code} | {gkd_info} | {snap.gkd_user_id} |" + f"| {snap.app_version_code} | {snap.id_qf_count}/{snap.text_qf_count} | {snap.max_depth} " + f"| {snap.clickable_nodes} | {snap.total_nodes} | {gkd_info} | {snap.gkd_user_id} |" ) lines.append("") @@ -304,6 +309,3 @@ def _deduplicate_devices(snapshots: list[SnapshotInfo]) -> list[dict]: ) return result - - -# ── 工具函数 ── From 07aa695e54de7bd24bfbe03251ef70e8f43c280a Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Thu, 16 Jul 2026 23:03:18 +0800 Subject: [PATCH 86/90] =?UTF-8?q?fix(py):=20=E4=BF=AE=E5=A4=8D=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E8=A7=A3=E6=9E=90=E9=80=BB=E8=BE=91=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 GKD 信息回退位置:从 device 对象读取 gkdVersionName/gkdVersionCode - 添加 quickFind 字段兼容(2023-10-16 ~ 2024-01 快照) - 添加 isClickable 回退兼容旧版快照 - 添加 is_legacy_snapshot 标记,输出旧版格式警告 - 更新测试用例适配新输出格式 --- scripts/python/core/snapshot_parser.py | 37 ++++++++++++++++++-------- scripts/python/formatter.py | 18 ++++++------- scripts/python/tests/test_formatter.py | 11 ++++---- scripts/python/utils/models.py | 3 +++ 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/scripts/python/core/snapshot_parser.py b/scripts/python/core/snapshot_parser.py index 705fdc568..4e42352a7 100644 --- a/scripts/python/core/snapshot_parser.py +++ b/scripts/python/core/snapshot_parser.py @@ -101,14 +101,17 @@ def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> Snapsh app_version_name = str(app_info.get("versionName") or data.get("appVersionName", "")) app_version_code = str(app_info.get("versionCode") or data.get("appVersionCode", "")) - # GKD 信息:优先 gkdAppInfo,回退顶层字段 + # 设备信息(需要先获取,因为 gkdVersionName/gkdVersionCode 旧版位于 device 内) + device = data.get("device", {}) or {} + + # GKD 信息:优先 gkdAppInfo,回退 device 对象内的字段 gkd_info = data.get("gkdAppInfo", {}) or {} - gkd_version_name = str(gkd_info.get("versionName") or data.get("gkdVersionName", "")) - gkd_version_code = str(gkd_info.get("versionCode") or data.get("gkdVersionCode", "")) + gkd_version_name = str(gkd_info.get("versionName") or device.get("gkdVersionName", "")) + gkd_version_code = str(gkd_info.get("versionCode") or device.get("gkdVersionCode", "")) gkd_user_id = str(gkd_info.get("userId", "")) - # 设备信息 - device = data.get("device", {}) or {} + # 旧版快照标记:检测是否缺少 appInfo/gkdAppInfo(旧版格式) + is_legacy_snapshot = "appInfo" not in data or data.get("appInfo") is None # 节点统计 nodes = data.get("nodes", []) or [] @@ -124,18 +127,29 @@ def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> Snapsh if attr.get("visibleToUser", False): visible_nodes += 1 - if attr.get("clickable", False): + # 兼容旧版 isClickable 字段 + if attr.get("clickable", False) or attr.get("isClickable", False): clickable_nodes += 1 depth = attr.get("depth", 0) if depth > max_depth: max_depth = depth - # idQf / textQf 缺失视为 null,仅 true 时计数 - if node.get("idQf") is True: - id_qf_count += 1 - if node.get("textQf") is True: - text_qf_count += 1 + # 快速查询标志处理:优先 idQf/textQf,回退 quickFind(2023-10-16 ~ 2024-01 快照) + id_qf = node.get("idQf") + text_qf = node.get("textQf") + if id_qf is not None or text_qf is not None: + # 当前版本:直接使用 idQf/textQf + if id_qf is True: + id_qf_count += 1 + if text_qf is True: + text_qf_count += 1 + else: + # 旧版兼容:使用 quickFind 字段(同时作为 idQf 和 textQf) + quick_find = node.get("quickFind") + if quick_find is True: + id_qf_count += 1 + text_qf_count += 1 return SnapshotInfo( app_name=app_name, @@ -164,4 +178,5 @@ def _parse_snapshot(data: dict, original_url: str, converted_url: str) -> Snapsh text_qf_count=text_qf_count, original_url=original_url, converted_url=converted_url, + is_legacy_snapshot=is_legacy_snapshot, ) diff --git a/scripts/python/formatter.py b/scripts/python/formatter.py index b817f1217..391a87264 100644 --- a/scripts/python/formatter.py +++ b/scripts/python/formatter.py @@ -167,8 +167,10 @@ def _render_app_section( if not snapshots: return - # App 标题 - lines.append(f"## {app_key}") + # App 标题(含旧版快照标记) + is_legacy = any(s.is_legacy_snapshot for s in snapshots) + legacy_tag = " `⚠️旧版快照`" if is_legacy else "" + lines.append(f"## {app_key}{legacy_tag}") # App 副标题:设备 + Android + GKD(取第一个快照的设备信息) first = snapshots[0] @@ -207,13 +209,7 @@ def _render_activity_line(lines: list[str], snap: SnapshotInfo, links: list[tupl # Activity 名称取最后一段(类名简写) act_display = short_activity_name(snap.activity_id) - # 统计信息行 - # stats = ( - # f"快查 ID:{snap.id_qf_count} Text:{snap.text_qf_count}" - # f" · 深度{snap.max_depth}" - # f" · 可点击{snap.clickable_nodes}" - # f" · {snap.total_nodes}节点" - # ) + # 统计信息已折叠到详细信息区域 lines.append(f"**{act_display}**") # 链接行(该 Activity 的所有快照链接) @@ -246,7 +242,9 @@ def _render_detail_section(snapshots: list[SnapshotInfo]) -> list[str]: app_groups.setdefault(app_key, []).append(snap) for app_key, app_snaps in app_groups.items(): - lines.append(f"**{app_key}**") + is_legacy = any(s.is_legacy_snapshot for s in app_snaps) + legacy_tag = " `⚠️旧版快照`" if is_legacy else "" + lines.append(f"**{app_key}{legacy_tag}**") lines.append("") lines.append( "| Activity | 可见 | 分辨率 | 方向 | APP版本代码 | 快查ID/Text | 深度 | 可点击 | 节点数 | GKD | userId |" diff --git a/scripts/python/tests/test_formatter.py b/scripts/python/tests/test_formatter.py index b38ec715b..35226a300 100644 --- a/scripts/python/tests/test_formatter.py +++ b/scripts/python/tests/test_formatter.py @@ -160,12 +160,11 @@ def test_single_app(self): self.assertIn("## 测试应用 `com.test.app` 1.0.0", result) # Activity 行 self.assertIn("**MainActivity**", result) - # 统计信息 - self.assertIn("快查 ID:2", result) - self.assertIn("Text:1", result) - self.assertIn("深度10", result) - self.assertIn("可点击3", result) - self.assertIn("20节点", result) + # 统计信息在折叠区域详细信息表中 + self.assertIn("2/1", result) # 快查ID:2/Text:1 + self.assertIn("| 10 |", result) # 深度10 + self.assertIn("| 3 |", result) # 可点击3 + self.assertIn("| 20 |", result) # 节点数20 def test_multiple_activities(self): """多 Activity 应各有独立行""" diff --git a/scripts/python/utils/models.py b/scripts/python/utils/models.py index d5c79b784..f5df36746 100644 --- a/scripts/python/utils/models.py +++ b/scripts/python/utils/models.py @@ -104,6 +104,9 @@ class SnapshotInfo: original_url: str converted_url: str + # 旧版快照标记 + is_legacy_snapshot: bool = False # 是否为旧版快照(缺少 appInfo/gkdAppInfo) + # ── 检查报告数据结构 ── From 0549919d0a69bde8c47d8239678aff23af0edcde Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 17 Jul 2026 00:32:37 +0800 Subject: [PATCH 87/90] =?UTF-8?q?perf(ci):=20=E6=94=B9=E4=B8=BAuv=20&&=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BC=93=E5=AD=98=20&=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 27 ++++++++++++++--------- scripts/python/requirements.txt | 1 + 2 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 scripts/python/requirements.txt diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index 9eb2ee38e..be00043ba 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -9,8 +9,6 @@ on: permissions: contents: read issues: write - actions: write - repository-projects: read env: GH_REPO: ${{ github.repository }} @@ -72,11 +70,20 @@ jobs: with: python-version: '3.12' + - name: 安装UV + if: >- + github.event_name != 'issue_comment' || + steps.pre-check.outputs.needs_analysis != 'false' + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: scripts/python/requirements.txt + - name: 安装 Python 依赖 if: >- github.event_name != 'issue_comment' || steps.pre-check.outputs.needs_analysis != 'false' - run: pip install httpx + run: uv pip install --system -r scripts/python/requirements.txt - name: 创建工作流所需标签 if: >- @@ -117,6 +124,10 @@ jobs: # 恢复快照缓存(所有事件都可读取) - name: 恢复快照缓存 + if: >- + github.event_name != 'issue_comment' || + steps.pre-check.outputs.needs_analysis != 'false' + uses: actions/cache/restore@v6 with: path: /tmp/snapshot_cache @@ -151,12 +162,12 @@ jobs: comment_bot= EOF else - python3 scripts/python/entry/check_issue.py + python scripts/python/entry/check_issue.py fi # 保存快照缓存(仅 issues 事件有写权限,issue_comment 事件只读) - name: 保存快照缓存 - if: github.event_name == 'issues' + if: github.event_name == 'issues' && !cancelled() uses: actions/cache/save@v6 with: path: /tmp/snapshot_cache @@ -302,11 +313,7 @@ jobs: handle-convert: name: 处理链接转换 - needs: - [ - analyze, - handle-missing-snapshot, - ] + needs: [analyze, handle-missing-snapshot] if: >- always() && needs.handle-missing-snapshot.result == 'skipped' && diff --git a/scripts/python/requirements.txt b/scripts/python/requirements.txt new file mode 100644 index 000000000..0c44139e0 --- /dev/null +++ b/scripts/python/requirements.txt @@ -0,0 +1 @@ +httpx@latest From 3115a72b056fa3fa98e52eb8fa065bbe16e1d9b0 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 17 Jul 2026 01:00:00 +0800 Subject: [PATCH 88/90] =?UTF-8?q?fix(ci):=20npm@=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E8=AF=AF=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/python/requirements.txt b/scripts/python/requirements.txt index 0c44139e0..f7621d172 100644 --- a/scripts/python/requirements.txt +++ b/scripts/python/requirements.txt @@ -1 +1 @@ -httpx@latest +httpx From e7f65a9504113326219adbc3d81ca3dc76e83799 Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 17 Jul 2026 01:07:41 +0800 Subject: [PATCH 89/90] =?UTF-8?q?perf(ci):=20Node.js=2020=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/issue_content_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue_content_check.yml b/.github/workflows/issue_content_check.yml index be00043ba..7387f6b34 100644 --- a/.github/workflows/issue_content_check.yml +++ b/.github/workflows/issue_content_check.yml @@ -74,7 +74,7 @@ jobs: if: >- github.event_name != 'issue_comment' || steps.pre-check.outputs.needs_analysis != 'false' - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v8.0.0 with: enable-cache: true cache-dependency-glob: scripts/python/requirements.txt From 4365b49e8d50c9adf2d744ce15d37326c25fcbee Mon Sep 17 00:00:00 2001 From: Ling0402 Date: Fri, 17 Jul 2026 01:45:40 +0800 Subject: [PATCH 90/90] =?UTF-8?q?fix(py):=20=E4=BB=A3=E7=90=86=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E8=A2=AB=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/python/api/base.py | 8 +++++++ scripts/python/core/checker.py | 19 +++++++++++++--- scripts/python/core/extractor.py | 31 +++++++++++++++++++++----- scripts/python/tests/test_extractor.py | 29 +++++++++++++++++++++--- scripts/python/utils/common.py | 2 +- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/scripts/python/api/base.py b/scripts/python/api/base.py index 16d155415..d22f97dd0 100644 --- a/scripts/python/api/base.py +++ b/scripts/python/api/base.py @@ -106,6 +106,8 @@ def get_check_url(self, link: LinkInfo) -> str | None: """ if link.kind == "github_attachment": return link.url + elif link.kind == "gkd_proxy": + return gkd_to_gh_attachment_url(link.url) elif link.kind == "gkd": return gkd_to_gh_attachment_url(link.url) return None @@ -170,6 +172,8 @@ def parse_snapshot(self, link: LinkInfo, check_url: str) -> SnapshotInfo | None: # 确定转换后的 URL(用于 Bot 评论展示) if link.kind == "github_attachment": converted_url = GKD_PROXY_TEMPLATE.format(url=link.url) + elif link.kind == "gkd_proxy": + converted_url = link.url else: converted_url = link.url @@ -202,6 +206,8 @@ def parse_all_snapshots(self, links: list[LinkInfo]) -> tuple[list[SnapshotInfo] # 更新 converted_url if lnk.kind == "github_attachment": snap.converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) + elif lnk.kind == "gkd_proxy": + snap.converted_url = lnk.url snapshots.append(snap) continue @@ -214,6 +220,8 @@ def parse_all_snapshots(self, links: list[LinkInfo]) -> tuple[list[SnapshotInfo] converted_url = GKD_PROXY_TEMPLATE.format(url=lnk.url) display = lnk.display_text or converted_url.split("/")[-1] gkd_links.append((display, converted_url)) + elif lnk.kind == "gkd_proxy": + gkd_links.append((lnk.display_text or lnk.url, lnk.url)) else: gkd_links.append((lnk.display_text or lnk.url, lnk.url)) continue diff --git a/scripts/python/core/checker.py b/scripts/python/core/checker.py index d1f1e4dd6..e49a7c715 100644 --- a/scripts/python/core/checker.py +++ b/scripts/python/core/checker.py @@ -23,6 +23,9 @@ # 从 GKD 分享链接中提取数字 ID _RE_GKD_ID = re.compile(r"https://i\.gkd\.li/i/(\d+)") +# 从 GKD 代理链接中提取真实 GitHub 附件 URL +_RE_GKD_PROXY = re.compile(r"https://i\.gkd\.li/i\?url=(https://github\.com/user-attachments/files/[^\s]+)") + # GH 附件 URL 模板:{id} 为 GKD 链接中的数字,file.zip 为固定占位符 _GH_ATTACHMENT_TEMPLATE = "https://github.com/user-attachments/files/{id}/file.zip" @@ -31,11 +34,21 @@ def gkd_to_gh_attachment_url(gkd_url: str) -> str | None: """ 将 GKD 分享链接转换为 GitHub 附件 URL,用于网络可访问性检查。 - 例如:https://i.gkd.li/i/29722723 → https://github.com/user-attachments/files/29722723/file.zip + 支持两种格式: + 1. 标准 GKD 分享链接:https://i.gkd.li/i/29722723 → https://github.com/user-attachments/files/29722723/file.zip + 2. GKD 代理链接:https://i.gkd.li/i?url=https://github.com/user-attachments/files/... → 提取真实 GitHub 附件 URL - 返回 None 表示 URL 不符合 GKD 分享链接格式。 + 返回 None 表示 URL 不符合已知格式。 """ - match = _RE_GKD_ID.search(gkd_url.strip()) + gkd_url = gkd_url.strip() + + # 优先尝试代理链接格式 + proxy_match = _RE_GKD_PROXY.search(gkd_url) + if proxy_match: + return proxy_match.group(1) + + # 标准数字 ID 格式 + match = _RE_GKD_ID.search(gkd_url) if not match: return None return _GH_ATTACHMENT_TEMPLATE.format(id=match.group(1)) diff --git a/scripts/python/core/extractor.py b/scripts/python/core/extractor.py index c7b7fa65d..65ff00a66 100644 --- a/scripts/python/core/extractor.py +++ b/scripts/python/core/extractor.py @@ -8,6 +8,8 @@ 从旧 Bot 评论中提取快照链接: - extract_links_from_bot_comment:提取 [snapshot_id](url) 格式的链接 + - 代理链接 (i.gkd.li/i?url=...) 分类为 "gkd_proxy" + - 标准链接 (i.gkd.li/i/数字) 分类为 "gkd" 本模块只负责提取和分类,不做任何检查或判断。 """ @@ -30,6 +32,9 @@ # 不可访问的快照链接:https://i.gkd.li/snapshot/... _RE_UNREACHABLE_SNAPSHOT = re.compile(r"https://i\.gkd\.li/snapshot/[^\s\)]*") +# GKD 代理链接:https://i.gkd.li/i?url=... +_RE_GKD_PROXY_LINK = re.compile(r"https://i\.gkd\.li/i\?url=(https://[^\s\)]+)") + # 从旧 Bot 评论中提取快照链接:[snapshot_id](url) 格式 # snapshot_id 是数字 ID(通常是 10 位以上的时间戳) _RE_BOT_SNAPSHOT_LINK = re.compile(r"\[(\d{10,})\]\((https://[^\)]+)\)") @@ -104,15 +109,30 @@ def extract_links(body: str) -> list[LinkInfo]: # ── 从旧 Bot 评论中提取链接 ── +def _classify_bot_url(url: str) -> str: + """ + 对旧 Bot 评论中提取的 URL 进行分类。 + + 返回值: + - "gkd_proxy":GKD 代理链接(i.gkd.li/i?url=...),内含 GitHub 附件地址 + - "gkd":标准 GKD 分享链接(i.gkd.li/i/数字) + """ + if _RE_GKD_PROXY_LINK.match(url): + return "gkd_proxy" + return "gkd" + + def extract_links_from_bot_comment(comment: str) -> list[LinkInfo]: """ 从旧 Bot 评论中提取快照链接。 旧 Bot 评论格式: - - [snapshot_id](url) 格式的链接(GitHub 附件代理链接) - - https://i.gkd.li/i/数字 格式的 GKD 链接(纯文本) + - [snapshot_id](url) 格式的链接 + - 代理链接:url 为 i.gkd.li/i?url=GitHub附件地址,kind 为 "gkd_proxy" + - 标准链接:url 为 i.gkd.li/i/数字,kind 为 "gkd" + - https://i.gkd.li/i/数字 格式的 GKD 链接(纯文本),kind 为 "gkd" - 返回:LinkInfo 列表,kind 统一为 "gkd"(用于后续处理) + 返回:LinkInfo 列表,kind 根据 URL 格式正确分类 """ if not comment: return [] @@ -126,15 +146,14 @@ def extract_links_from_bot_comment(comment: str) -> list[LinkInfo]: url = match.group(2) if url not in seen: seen.add(url) - # 使用原始 URL(已经是 GKD 代理链接格式) - results.append(LinkInfo(url=url, kind="gkd", display_text=snapshot_id)) + kind = _classify_bot_url(url) + results.append(LinkInfo(url=url, kind=kind, display_text=snapshot_id)) # 提取纯文本 GKD 链接 for match in _RE_BOT_GKD_LINK.finditer(comment): url = match.group(0) if url not in seen: seen.add(url) - # 从 URL 中提取 ID snapshot_id = url.split("/")[-1] results.append(LinkInfo(url=url, kind="gkd", display_text=snapshot_id)) diff --git a/scripts/python/tests/test_extractor.py b/scripts/python/tests/test_extractor.py index 66af95738..b6f318e42 100644 --- a/scripts/python/tests/test_extractor.py +++ b/scripts/python/tests/test_extractor.py @@ -84,15 +84,38 @@ class TestExtractLinksFromBotComment(unittest.TestCase): """测试 extract_links_from_bot_comment() 函数""" def test_bot_snapshot_link(self): - """[snapshot_id](url) 格式应从 snapshot_id 构造 GKD 链接""" + """[snapshot_id](url) 格式(标准 GKD 链接)应分类为 gkd""" comment = "[1783704841971](https://i.gkd.li/i/29899905)" result = extract_links_from_bot_comment(comment) self.assertEqual(len(result), 1) - # 函数从 snapshot_id 构造 GKD URL,不是用原始 URL - self.assertEqual(result[0].url, "https://i.gkd.li/i/1783704841971") + self.assertEqual(result[0].url, "https://i.gkd.li/i/29899905") self.assertEqual(result[0].kind, "gkd") self.assertEqual(result[0].display_text, "1783704841971") + def test_bot_proxy_link(self): + """[snapshot_id](url) 格式(代理链接)应分类为 gkd_proxy""" + comment = "[1773646272170](https://i.gkd.li/i?url=https://github.com/user-attachments/files/26105236/_MainTabActivity-1773646272170.zip)" + result = extract_links_from_bot_comment(comment) + self.assertEqual(len(result), 1) + self.assertEqual( + result[0].url, + "https://i.gkd.li/i?url=https://github.com/user-attachments/files/26105236/_MainTabActivity-1773646272170.zip", + ) + self.assertEqual(result[0].kind, "gkd_proxy") + self.assertEqual(result[0].display_text, "1773646272170") + + def test_bot_mixed_links(self): + """代理链接和标准链接混合应正确分类""" + comment = ( + "[1773646272170](https://i.gkd.li/i?url=https://github.com/user-attachments/files/26105236/_MainTabActivity-1773646272170.zip)" + " · " + "[1783207234571](https://i.gkd.li/i/29666442)" + ) + result = extract_links_from_bot_comment(comment) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].kind, "gkd_proxy") + self.assertEqual(result[1].kind, "gkd") + def test_bot_gkd_link(self): """纯 GKD 链接应正确提取""" comment = "https://i.gkd.li/i/29899905" diff --git a/scripts/python/utils/common.py b/scripts/python/utils/common.py index 9d3974707..1077334d9 100644 --- a/scripts/python/utils/common.py +++ b/scripts/python/utils/common.py @@ -14,7 +14,7 @@ # ── 常量 ── # 快照相关链接类型集合 -SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot"} +SNAPSHOT_KINDS = {"gkd", "github_attachment", "unreachable_snapshot", "gkd_proxy"} # ── URL 处理函数 ──