From 54b6368340ba11e7e4f9772727ee95d0cbb3e948 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 11 Oct 2026 12:18:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8B=AC=E7=AB=8B=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E9=87=87=E9=9B=86=E5=99=A8=E5=B9=B6=E6=8C=89?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8F=98=E5=8C=96=E9=80=92=E5=A2=9E=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/workflows/site-adapter-collector.yml | 145 +++++++++++++++++-- README.md | 1 + docs/development-setup.md | 2 + docs/site-adapter-capture.md | 2 +- docs/site-adapter-collector-release.md | 16 +- docs/v2-to-v3-overview.md | 2 +- scripts/collect-site-adapter.sh | 2 +- scripts/collector/__init__.py | 1 + scripts/collector/release.py | 116 +++++++++++++++ scripts/site_adapter_collector.py | 3 +- tests/test_site_adapter_collector_release.py | 137 ++++++++++++++++++ tests/test_system_update_manager.py | 2 + 13 files changed, 407 insertions(+), 24 deletions(-) create mode 100644 scripts/collector/__init__.py create mode 100644 scripts/collector/release.py create mode 100644 tests/test_site_adapter_collector_release.py diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b8ece30a8..9dc8afef0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,7 +8,7 @@ body: value: | 请说明你希望添加的功能。 - 站点适配请求请先按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。 + 站点适配请求请从 [采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 下载最新采集器,再按 [站点适配采集说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-capture.md) 生成脱敏 ZIP,并在下方附加。Issue 及附件是公开内容,提交前必须解压预览四个文件。不要上传 Cookie、Authorization、通行密钥、会话字段或任何原始数据。 - type: input id: version attributes: diff --git a/.github/workflows/site-adapter-collector.yml b/.github/workflows/site-adapter-collector.yml index 3f2f069f9..dfa9f569a 100644 --- a/.github/workflows/site-adapter-collector.yml +++ b/.github/workflows/site-adapter-collector.yml @@ -1,22 +1,73 @@ name: Site Adapter Collector on: + push: + branches: + - v3 + paths: + - scripts/site_adapter_collector.py + - scripts/site_adapter_collector.spec + - scripts/site_adapter_collector_requirements.txt + - scripts/start-site-adapter-collector.command + - scripts/collect-site-adapter.sh + - scripts/collector/** + - .github/workflows/site-adapter-collector.yml workflow_dispatch: inputs: - release_tag: - description: Existing release tag to receive collector assets; leave empty for artifacts only - required: false - type: string - release: - types: - - published + publish: + description: Publish an independent release only when collector inputs changed + required: true + default: true + type: boolean + +# 串行选择版本和发布,避免多个采集器提交争抢同一个版本。 +concurrency: + group: site-adapter-collector-release + cancel-in-progress: false permissions: contents: read jobs: + prepare: + name: Check collector changes and select independent version + if: github.ref == 'refs/heads/v3' + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.version.outputs.changed }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + previous_tag: ${{ steps.version.outputs.previous_tag }} + source_sha: ${{ steps.version.outputs.source_sha }} + steps: + - name: Checkout current collector source + uses: actions/checkout@v7 + with: + # 排队期间可能已有新提交;统一构建当前 v3,避免旧任务回退采集器。 + ref: v3 + fetch-depth: 0 + fetch-tags: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + + - name: Select collector release version + id: version + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --paginate \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' \ + > collector-release-tags.txt + python scripts/collector/release.py plan \ + --tags-file collector-release-tags.txt --output "$GITHUB_OUTPUT" + build: name: Build ${{ matrix.platform_name }} collector + needs: prepare + if: needs.prepare.outputs.changed == 'true' || (github.event_name == 'workflow_dispatch' && !inputs.publish) runs-on: ${{ matrix.runner }} timeout-minutes: 30 strategy: @@ -45,6 +96,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.source_sha }} - name: Set up Python uses: actions/setup-python@v7 @@ -61,14 +114,20 @@ jobs: run: uv pip install --system --requirement scripts/site_adapter_collector_requirements.txt - name: Build single-file collector + shell: bash + env: + COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }} run: | + python scripts/collector/release.py stamp --version "$COLLECTOR_VERSION" pyinstaller --clean --noconfirm scripts/site_adapter_collector.spec - name: Smoke-test collector env: SOURCE_NAME: ${{ matrix.source_name }} + COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }} run: | python -c "import os, subprocess; from pathlib import Path; subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--help'], check=True)" + python -c "import os, subprocess; from pathlib import Path; result = subprocess.run([str((Path('dist') / os.environ['SOURCE_NAME']).resolve()), '--version'], check=True, capture_output=True, text=True); assert result.stdout.strip() == os.environ['COLLECTOR_VERSION'], result.stdout" - name: Package macOS double-click archive if: matrix.platform_id == 'macos' @@ -98,7 +157,7 @@ jobs: env: ASSET_NAME: ${{ matrix.asset_name }} run: | - python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8')" + python -c "import hashlib, os; from pathlib import Path; path = Path('dist') / os.environ['ASSET_NAME']; path.with_name(path.name + '.sha256').write_text(f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n', encoding='utf-8', newline='\n')" - name: Upload collector artifact uses: actions/upload-artifact@v7 @@ -111,15 +170,23 @@ jobs: retention-days: 3 publish: - name: Upload collectors to release - if: github.event_name == 'release' || inputs.release_tag != '' + name: Publish independent collector release + if: needs.prepare.outputs.changed == 'true' && (github.event_name == 'push' || inputs.publish) needs: + - prepare - build runs-on: ubuntu-latest permissions: contents: write steps: + - name: Checkout release source + uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.source_sha }} + fetch-depth: 0 + fetch-tags: false + - name: Download collector artifacts uses: actions/download-artifact@v8 with: @@ -127,10 +194,60 @@ jobs: path: release-assets merge-multiple: true - - name: Upload assets to published release + - name: Generate collector release notes + env: + COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }} + PREVIOUS_TAG: ${{ needs.prepare.outputs.previous_tag }} + SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} + run: | + if [[ -n "$PREVIOUS_TAG" ]]; then + git fetch --no-tags origin "refs/tags/$PREVIOUS_TAG:refs/tags/$PREVIOUS_TAG" + fi + python - <<'PY' + import os + import subprocess + from pathlib import Path + from scripts.collector.release import RELEASE_PATHS + + version = os.environ['COLLECTOR_VERSION'] + previous = os.environ['PREVIOUS_TAG'] + revision = f"{previous}..HEAD" if previous else "HEAD" + records = subprocess.check_output( + ['git', 'log', revision, '--no-merges', '--format=- %s (%h) — %an', '--', *RELEASE_PATHS], + text=True, + ).strip() + notes = ( + f"MoviePilot 站点适配采集器 {version},独立于后端版本发布。\n\n" + "下载与系统对应的程序及同名 `.sha256` 校验文件。\n\n" + "[使用说明](https://github.com/jxxghp/MoviePilot/blob/v3/docs/site-adapter-collector-release.md)\n\n" + f"源码提交:`{os.environ['SOURCE_SHA']}`\n\n## 变更记录\n\n{records}\n" + ) + Path('collector-release-notes.md').write_text(notes, encoding='utf-8') + PY + + - name: Create and publish independent collector release env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + COLLECTOR_VERSION: ${{ needs.prepare.outputs.version }} + SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} run: | - gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null - gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq '.isDraft' > collector-is-draft.txt; then + [[ "$(cat collector-is-draft.txt)" == "true" ]] + git fetch --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + [[ "$(git rev-parse "$RELEASE_TAG^{commit}")" == "$SOURCE_SHA" ]] + gh release upload "$RELEASE_TAG" release-assets/* \ + --clobber --repo "$GITHUB_REPOSITORY" + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --title "Site Adapter Collector v$COLLECTOR_VERSION" \ + --notes-file collector-release-notes.md + else + gh release create "$RELEASE_TAG" release-assets/* \ + --repo "$GITHUB_REPOSITORY" --target "$SOURCE_SHA" --draft \ + --title "Site Adapter Collector v$COLLECTOR_VERSION" \ + --notes-file collector-release-notes.md --latest=false + fi + # 全部附件上传完成才转正式发布,上传失败不会消耗正式版本。 + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --draft=false --latest=false diff --git a/README.md b/README.md index 2450a05e8..7232db13f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst - 开发环境与本地源码运行:[docs/development-setup.md](docs/development-setup.md) - 测试说明:[docs/testing.md](docs/testing.md) - 新站点适配采集与 Feature Request 提交:[docs/site-adapter-capture.md](docs/site-adapter-capture.md) +- 站点采集器下载:[独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true),仅在采集器变化时递增版本并发布。 - REST API 文档:https://api.movie-pilot.org - 插件开发说明:https://wiki.movie-pilot.org/zh/plugindev diff --git a/docs/development-setup.md b/docs/development-setup.md index c72602bdc..663915d22 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -234,6 +234,8 @@ Docker 镜像直接基于官方基础镜像构建,只安装应用运行所需 报告,不自动更新基线。普通主仓改动不要求额外检出插件仓或运行观察任务。宿主架构门禁和 changed-file Pylint 由 `v3` PR/push 的 GitHub Actions 执行,`app/` 全量 Pylint 是建议性报告。 +站点采集器使用独立的 `site-adapter-collector-v1.0.x` Release,仅在采集器发布输入变化时递增版本并构建,不跟随后端 Release。下载入口为 [采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true),自动版本和手动测试构建规则见 [采集器发布说明](site-adapter-collector-release.md#维护者发布流程)。 + Ruff/Mypy 基线只允许收紧,不接受新增诊断或类型错误增长;受治零错误文件由 `mypy.ini` 的 `files=` 维护。Mypy 完整 ratchet 固定按 Linux/Python 3.14 分析。Coverage job 在 `v3` PR/push 运行,按 `tests/run.py` 的分片合同执行并合并报告,Application 与 Domain 固定不低于 80%。 diff --git a/docs/site-adapter-capture.md b/docs/site-adapter-capture.md index 6c1bc5d8f..6e8a2f476 100644 --- a/docs/site-adapter-capture.md +++ b/docs/site-adapter-capture.md @@ -6,7 +6,7 @@ 普通用户只需准备两样东西:目标站点账号,以及已安装的 Chrome、Edge 或 Chromium。无需安装 Python、Git、MoviePilot、Docker,也不需要查看 HTML、复制 Cookie 或填写 User-Agent。 -1. 从 MoviePilot 官方 Release 下载与 Windows、macOS 或 Linux 对应的 `moviepilot-site-collector-*` 单文件采集器。 +1. 从官方 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 选择最新采集器版本,下载与 Windows、macOS 或 Linux 对应的程序;各系统文件名见下方下载说明。 2. 运行采集器,只输入站点首页地址,例如 `https://tracker.example.com`。 3. 程序会打开一个临时浏览器窗口。在这个窗口里正常登录站点,搜索一个能返回至少 3 条结果的常见关键词,并保持搜索结果页打开。 4. 回到采集器按回车。程序会自动识别搜索地址、关键词、Cookie 和 User-Agent,完成本地裁剪与脱敏后生成 ZIP。 diff --git a/docs/site-adapter-collector-release.md b/docs/site-adapter-collector-release.md index dbc3f3319..254d0ee04 100644 --- a/docs/site-adapter-collector-release.md +++ b/docs/site-adapter-collector-release.md @@ -1,10 +1,10 @@ # 站点适配采集器下载说明 -普通用户优先使用 MoviePilot 正式 Release 提供的单文件采集器。单文件已经包含 Python 和采集器依赖,不需要安装 Python、pip、Git、MoviePilot 后端,也不需要下载源码。电脑只需已安装 Chrome、Edge 或 Chromium 浏览器。 +普通用户优先使用 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 提供的单文件采集器,选择列表中最新版本。采集器使用 `site-adapter-collector-v1.0.x` 独立标签,与 MoviePilot 后端版本分开发布。单文件已经包含 Python 和采集器依赖,不需要安装 Python、pip、Git、MoviePilot 后端,也不需要下载源码。电脑只需已安装 Chrome、Edge 或 Chromium 浏览器。 ## 选择下载文件 -请只从 MoviePilot 官方 GitHub Release 下载与系统匹配的文件: +请只从官方 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 下载与系统匹配的文件;MoviePilot 主程序 Release 不再提供采集器附件: | 系统 | 下载文件 | 用户侧运行环境 | |---|---|---| @@ -12,7 +12,7 @@ | macOS | `MoviePilot-Site-Collector-macOS.zip` | Chrome、Edge 或 Chromium | | Linux | `moviepilot-site-collector-linux` | Chrome、Edge 或 Chromium | -每个程序旁边还有同名的 `.sha256` 文件,可用于核对下载文件是否完整。GitHub Actions 的手动构建产物主要用于维护者测试;普通用户应使用正式 Release 资产。 +每个程序旁边还有同名的 `.sha256` 文件,可用于核对下载文件是否完整。程序启动时显示版本,也可执行 `--version` 查看。GitHub Actions 的手动构建产物主要用于维护者测试;普通用户应使用采集器独立 Release 资产。 ## 运行采集器 @@ -27,10 +27,16 @@ chmod +x moviepilot-site-collector-linux ## 系统安全提示 -当前自动构建产物尚未接入 Windows 或 Apple 代码签名。Windows SmartScreen 或 macOS Gatekeeper 可能因此显示安全提示。仅在文件来自 MoviePilot 官方 GitHub Release,且校验摘要一致时运行;不要从聊天、网盘或第三方站点接收采集器。 +当前自动构建产物尚未接入 Windows 或 Apple 代码签名。Windows SmartScreen 或 macOS Gatekeeper 可能因此显示安全提示。仅在文件来自官方 [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true),且校验摘要一致时运行;不要从聊天、网盘或第三方站点接收采集器。 如果系统阻止运行,可改用随 MoviePilot 源码提供的本地采集脚本;该方式需要 Python 3.14+ 及完整后端依赖,不适合作为普通用户的首选路径。 ## 维护者发布流程 -`.github/workflows/site-adapter-collector.yml` 支持手动触发,也会在 Release 发布后自动构建 Windows、macOS 和 Linux 单文件程序。每个平台先执行 `--help` 启动检查,再上传程序及 SHA-256 摘要为 Workflow Artifact。Release 事件会在三个平台全部成功后,把文件附加到触发本次任务的 Release;手动触发时填写已有的 `release_tag` 也会上传到该 Release,留空则只生成 3 天的测试 Artifact。 +`.github/workflows/site-adapter-collector.yml` 在 `v3` 分支的采集器源码、依赖、启动脚本或打包工作流变化时触发,支持手动运行。后端 Release 发布及普通后端代码、文档、测试改动不会触发采集器构建。 + +构建前会与上一个已正式发布的采集器标签比较输入文件。没有变化就跳过构建和发版;首次发布沿用源码中的 `COLLECTOR_VERSION`(当前为 `1.0.1`),后续有变化时自动递增补丁版本。工作流只在 Runner 构建副本中写入版本,使程序、`--version` 和脱敏包中的 `collector_version` 一致,不向 `v3` 分支回写版本提交。采集包的 `format_version` 协议版本保持独立。 + +每个平台先执行 `--help` 和 `--version` 启动检查,再上传程序及 SHA-256 摘要。三个平台全部成功后,工作流创建独立版本 Release,自动生成仅包含采集器相关提交的变更记录,并设置 `latest=false`,使主程序的最新 Release 入口继续指向后端版本。 + +手动触发时 `publish` 默认为 `true`,仍遵守“输入变化才发版”。设为 `false` 时可重复构建测试,只生成保留 3 天的 Artifact,不创建 Release、不消耗版本号。历史主程序 Release 中的采集器附件会迁移移除,下载请始终使用上面的独立 Release 入口。 diff --git a/docs/v2-to-v3-overview.md b/docs/v2-to-v3-overview.md index 4a2518bad..8218fd9fc 100644 --- a/docs/v2-to-v3-overview.md +++ b/docs/v2-to-v3-overview.md @@ -399,7 +399,7 @@ MoviePilot/app/application/site - 后端用户数据解析器可以自动探测 Gazelle、NexusPHP 等站点变种。 - 解析模板支持更完整的标题、文件名、标签和可选字段回退。 - 构建产物覆盖 Linux、Windows、macOS、x86_64、Arm64,以及 V3t 所需的 Python 3.14t ABI。 -- 站点采集器可以生成脱敏样本,维护者可通过自动化流程匹配模板并创建待审核的适配 PR。 +- [站点采集器独立 Release](https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true) 提供生成脱敏样本的程序,维护者可通过自动化流程匹配模板并创建待审核的适配 PR。 ### 7.4 用户遇到站点问题时应如何判断 diff --git a/scripts/collect-site-adapter.sh b/scripts/collect-site-adapter.sh index 95551559c..074ab865b 100644 --- a/scripts/collect-site-adapter.sh +++ b/scripts/collect-site-adapter.sh @@ -86,7 +86,7 @@ main() { fi if ! project_runtime_ready; then - echo "本地 Python 环境缺少采集器依赖,请优先下载官方 Release 的独立采集器。" >&2 + echo "本地 Python 环境缺少采集器依赖,请下载采集器独立 Release:https://github.com/jxxghp/MoviePilot/releases?q=site-adapter-collector-v&expanded=true" >&2 exit 1 fi COLLECTOR_PATH="$PROJECT_ROOT/scripts/site_adapter_collector.py" diff --git a/scripts/collector/__init__.py b/scripts/collector/__init__.py new file mode 100644 index 000000000..1002a46f2 --- /dev/null +++ b/scripts/collector/__init__.py @@ -0,0 +1 @@ +"""站点采集器的独立发布工具。""" diff --git a/scripts/collector/release.py b/scripts/collector/release.py new file mode 100644 index 000000000..80421df3e --- /dev/null +++ b/scripts/collector/release.py @@ -0,0 +1,116 @@ +"""比较采集器发布输入,按独立 Release 递增版本并写入构建副本。""" + +import argparse +import re +import subprocess +from pathlib import Path +from typing import Optional + +TAG_PREFIX = "site-adapter-collector-v" +RELEASE_PATHS = ( + "scripts/site_adapter_collector.py", + "scripts/site_adapter_collector.spec", + "scripts/site_adapter_collector_requirements.txt", + "scripts/start-site-adapter-collector.command", + "scripts/collect-site-adapter.sh", + "scripts/collector", + ".github/workflows/site-adapter-collector.yml", +) +VERSION_PATTERN = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") +SOURCE_VERSION_PATTERN = re.compile(r'^COLLECTOR_VERSION = "([\d.]+)"$', re.MULTILINE) + + +def _version_parts(version: str) -> tuple[int, int, int]: + """校验独立版本,避免外部标签进入 Git 参数或构建源码。""" + match = VERSION_PATTERN.fullmatch(version) + if not match: + raise ValueError(f"无效采集器版本:{version}") + return tuple(int(part) for part in match.groups()) + + +def latest_release_tag(tags: list[str]) -> Optional[str]: + """从已正式发布的标签中选取采集器最高版本,忽略后端版本。""" + collector_tags = [ + tag for tag in tags + if tag.startswith(TAG_PREFIX) and VERSION_PATTERN.fullmatch(tag[len(TAG_PREFIX):]) + ] + return max(collector_tags, key=lambda tag: _version_parts(tag[len(TAG_PREFIX):]), default=None) + + +def release_plan(repository: Path, previous_tag: Optional[str]) -> dict[str, str]: + """仅比较采集器输入;未变化时跳过发布,首次沿用源码版本。""" + source = repository / "scripts/site_adapter_collector.py" + source_match = SOURCE_VERSION_PATTERN.search(source.read_text(encoding="utf-8")) + if not source_match: + raise ValueError("采集器源码缺少唯一版本声明") + source_version = source_match.group(1) + source_parts = _version_parts(source_version) + source_sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repository, text=True, + ).strip() + changed = True + version = source_version + if previous_tag: + previous_parts = _version_parts(previous_tag.removeprefix(TAG_PREFIX)) + result = subprocess.run( + ["git", "diff", "--quiet", previous_tag, "HEAD", "--", *RELEASE_PATHS], + cwd=repository, check=False, + ) + if result.returncode not in (0, 1): + raise RuntimeError(f"无法比较采集器 Release:{previous_tag}") + changed = result.returncode == 1 + if changed: + next_parts = max(source_parts, (*previous_parts[:2], previous_parts[2] + 1)) + version = ".".join(str(part) for part in next_parts) + else: + version = previous_tag[len(TAG_PREFIX):] + return { + "changed": str(changed).lower(), + "version": version, + "tag": f"{TAG_PREFIX}{version}", + "previous_tag": previous_tag or "", + "source_sha": source_sha, + } + + +def stamp_version(source: Path, version: str) -> None: + """只修改 Runner 构建副本,使程序及脱敏包版本与独立 Release 一致。""" + _version_parts(version) + content, count = SOURCE_VERSION_PATTERN.subn( + f'COLLECTOR_VERSION = "{version}"', source.read_text(encoding="utf-8"), + ) + if count != 1: + raise ValueError("采集器源码缺少唯一版本声明") + source.write_text(content, encoding="utf-8") + + +def main() -> None: + """供工作流选择独立发布版本或给构建副本写入版本。""" + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + plan_parser = commands.add_parser("plan") + plan_parser.add_argument("--tags-file", type=Path, required=True) + plan_parser.add_argument("--output", type=Path, required=True) + stamp_parser = commands.add_parser("stamp") + stamp_parser.add_argument("--version", required=True) + args = parser.parse_args() + repository = Path(__file__).resolve().parents[2] + if args.command == "stamp": + stamp_version(repository / "scripts/site_adapter_collector.py", args.version) + return + tags = args.tags_file.read_text(encoding="utf-8").splitlines() + previous_tag = latest_release_tag(tags) + if previous_tag: + subprocess.run( + ["git", "fetch", "--no-tags", "origin", f"refs/tags/{previous_tag}:refs/tags/{previous_tag}"], + cwd=repository, check=True, + ) + plan = release_plan(repository, previous_tag) + with args.output.open("a", encoding="utf-8") as output: + for name, value in plan.items(): + output.write(f"{name}={value}\n") + print(f"采集器版本:{plan['version']};输入变化:{plan['changed']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/site_adapter_collector.py b/scripts/site_adapter_collector.py index bfd1c0a7c..b4f7e6a4f 100644 --- a/scripts/site_adapter_collector.py +++ b/scripts/site_adapter_collector.py @@ -1394,6 +1394,7 @@ def collect_site_capture_with_browser(start_url: str, output_dir: Path) -> Path: def _parse_args() -> argparse.Namespace: """解析普通浏览器模式和高级手动 Cookie 模式的命令行参数。""" parser = argparse.ArgumentParser(description="采集并脱敏 MoviePilot 站点适配所需的搜索页结构") + parser.add_argument("--version", action="version", version=COLLECTOR_VERSION) parser.add_argument( "--output-dir", type=Path, @@ -1424,7 +1425,7 @@ def main() -> int: """交互式读取采集参数并生成站点适配 ZIP。""" _configure_standard_streams() args = _parse_args() - print("MoviePilot 站点适配采集器") + print(f"MoviePilot 站点适配采集器 {COLLECTOR_VERSION}") print("原始页面不会落盘;临时浏览器关闭后会清理登录状态,输出前会裁剪并脱敏。") try: if args.manual_cookie: diff --git a/tests/test_site_adapter_collector_release.py b/tests/test_site_adapter_collector_release.py new file mode 100644 index 000000000..b1ef6b261 --- /dev/null +++ b/tests/test_site_adapter_collector_release.py @@ -0,0 +1,137 @@ +"""验证采集器独立版本及发布边界,所有 Git 操作仅使用临时仓库。""" + +import subprocess +from pathlib import Path + +import pytest +from ruamel.yaml import YAML + +from scripts.collector import release + +PROJECT_ROOT = Path(__file__).parents[1] + + +def _git(repository: Path, *arguments: str) -> str: + """在临时仓库运行 Git,隔离真实仓库和用户提交身份。""" + return subprocess.check_output( + ["git", "-c", "user.name=Collector Test", "-c", "user.email=collector@example.invalid", + *arguments], cwd=repository, text=True, + ).strip() + + +@pytest.fixture +def collector_repository(tmp_path: Path) -> Path: + """创建已发布 1.0.1 的临时采集器仓库。""" + _git(tmp_path, "init", "-b", "v3") + for relative in release.RELEASE_PATHS: + path = tmp_path / relative + if relative == "scripts/collector": + path = path / "release.py" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("initial\n", encoding="utf-8") + (tmp_path / "scripts/site_adapter_collector.py").write_text( + 'COLLECTOR_VERSION = "1.0.1"\n', encoding="utf-8", + ) + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "initial collector") + _git(tmp_path, "tag", "site-adapter-collector-v1.0.1") + return tmp_path + + +def test_latest_release_ignores_backend_and_orders_versions_numerically(): + """后端版本和非法标签不得参与采集器版本排序。""" + assert release.latest_release_tag([ + "v3.1.4", "site-adapter-collector-v1.0.9", "site-adapter-collector-v1.0.10", + "site-adapter-collector-v1.0.11-rc1", "site-adapter-collector-v1.01.1", + ]) == "site-adapter-collector-v1.0.10" + assert release.latest_release_tag(["v3.1.4"]) is None + + +def test_first_release_uses_existing_collector_version(collector_repository: Path): + """首次独立发布沿用采集器版本,不能使用后端版本号。""" + plan = release.release_plan(collector_repository, None) + assert plan["tag"] == "site-adapter-collector-v1.0.1" + assert plan["changed"] == "true" + assert plan["source_sha"] == _git(collector_repository, "rev-parse", "HEAD") + + +def test_backend_and_document_changes_do_not_release(collector_repository: Path): + """后端和文档提交、手动重复执行都不能消耗采集器版本。""" + for name in ("version.py", "README.md", "tests/test_collector.py"): + path = collector_repository / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("unrelated\n", encoding="utf-8") + _git(collector_repository, "add", ".") + _git(collector_repository, "commit", "-m", "backend and docs") + for _ in range(2): + plan = release.release_plan(collector_repository, "site-adapter-collector-v1.0.1") + assert plan["changed"] == "false" + assert plan["version"] == "1.0.1" + + +@pytest.mark.parametrize("relative", release.RELEASE_PATHS) +def test_collector_input_change_increments_patch(collector_repository: Path, relative: str): + """采集器任意发布输入改变都必须生成下一个补丁版本。""" + path = collector_repository / relative + if relative == "scripts/collector": + path = path / "release.py" + with path.open("a", encoding="utf-8") as output: + output.write("# collector change\n") + _git(collector_repository, "add", ".") + _git(collector_repository, "commit", "-m", "change collector input") + plan = release.release_plan(collector_repository, "site-adapter-collector-v1.0.1") + assert plan["changed"] == "true" + assert plan["version"] == "1.0.2" + + +def test_subsequent_release_uses_published_version(collector_repository: Path): + """源码版本无需回写,下次补丁必须以正式 Release 版本为准。""" + _git(collector_repository, "tag", "site-adapter-collector-v1.0.9") + (collector_repository / "scripts/site_adapter_collector.spec").unlink() + _git(collector_repository, "add", ".") + _git(collector_repository, "commit", "-m", "remove collector build input") + assert release.release_plan( + collector_repository, "site-adapter-collector-v1.0.9", + )["version"] == "1.0.10" + + +def test_missing_previous_tag_fails_instead_of_publishing(collector_repository: Path): + """比较失败不能误判为采集器改变并发版。""" + with pytest.raises(RuntimeError, match="无法比较"): + release.release_plan(collector_repository, "site-adapter-collector-v1.0.8") + + +def test_stamp_sets_runtime_and_manifest_version(tmp_path: Path): + """构建版本写入现有声明,其他源码和采集包协议版本保持一致。""" + source = tmp_path / "collector.py" + source.write_text('COLLECTOR_VERSION = "1.0.1"\nFORMAT_VERSION = 1\n', encoding="utf-8") + release.stamp_version(source, "1.0.10") + assert source.read_text(encoding="utf-8") == 'COLLECTOR_VERSION = "1.0.10"\nFORMAT_VERSION = 1\n' + with pytest.raises(ValueError, match="无效采集器版本"): + release.stamp_version(source, "1.0.10\ninvalid") + + +def test_workflow_separates_releases_and_pins_all_build_sources(): + """工作流只跟随采集器输入,三平台成功才发布且保留主程序 latest。""" + workflow = YAML(typ="safe").load( + (PROJECT_ROOT / ".github/workflows/site-adapter-collector.yml").read_text(encoding="utf-8"), + ) + assert "release" not in workflow["on"] + assert workflow["on"]["push"]["branches"] == ["v3"] + paths = workflow["on"]["push"]["paths"] + assert set(paths) == set(release.RELEASE_PATHS) - {"scripts/collector"} | {"scripts/collector/**"} + assert workflow["concurrency"]["cancel-in-progress"] is False + jobs = workflow["jobs"] + assert "needs.prepare.outputs.changed == 'true'" in jobs["build"]["if"] + assert "!inputs.publish" in jobs["build"]["if"] + assert jobs["publish"]["needs"] == ["prepare", "build"] + for job in ("build", "publish"): + checkout = jobs[job]["steps"][0] + assert checkout["with"]["ref"] == "${{ needs.prepare.outputs.source_sha }}" + build_step = next(step for step in jobs["build"]["steps"] if step["name"] == "Build single-file collector") + assert build_step["shell"] == "bash" + assert "release.py stamp" in build_step["run"] + publish = jobs["publish"]["steps"][-1]["run"] + assert "--target \"$SOURCE_SHA\" --draft" in publish + assert "--draft=false --latest=false" in publish + assert '"$RELEASE_TAG^{commit}"' in publish diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index 2398ab9db..00d47a83d 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -142,9 +142,11 @@ def test_scheduled_check_respects_independent_switches( def test_check_exposes_new_stable_release(monkeypatch, tmp_path): + """主程序更新应跳过最新采集器独立 Release 和后端预发布版本。""" manager = _manager(monkeypatch, tmp_path) logs = [] releases = [ + {"tag_name": "site-adapter-collector-v1.0.1", "prerelease": False, "draft": False}, {"tag_name": "v3.2.0-beta", "prerelease": True, "draft": False}, { "tag_name": "v3.1.0",