Skip to content

Commit 2fce5e8

Browse files
HarryXin0919claude
andcommitted
Add cross-platform desktop app packaging (PyInstaller + GitHub Actions)
Bundle viralens as a downloadable Windows/macOS/Linux desktop app so non-technical users can double-click to run -- no Python install needed. - scripts/runtime.py: centralize source-vs-frozen differences (FROZEN detection, worker_cmd self-dispatch via --vl-exec, writable per-user data dir when frozen). Source-mode behavior is byte-for-byte unchanged. - Route the subprocess pipeline (app.py, viralens.py) and every worker script's data/reports/config paths through runtime. - packaging/: PyInstaller onedir spec + entry + local build helper. - .github/workflows/release.yml: tag v* builds on all three OSes and publishes a GitHub Release with per-OS downloads. - analyze_video.py: graceful degradation when ffmpeg is absent. - README: add a download-the-app section (EN + Chinese). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5667d9e commit 2fce5e8

25 files changed

Lines changed: 529 additions & 42 deletions

.github/workflows/release.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Release
2+
3+
# 打一个 v* 版本标签(例:git tag v0.2.0 && git push --tags)就会:
4+
# 在 Windows / macOS / Linux 各构建一份桌面 app,压缩,发布到一个 GitHub Release。
5+
# 也可在 Actions 页手动触发(workflow_dispatch)只构建、不发布。
6+
on:
7+
push:
8+
tags: ["v*"]
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: write # 发布 Release 需要写权限
13+
14+
jobs:
15+
build:
16+
name: Build (${{ matrix.os }})
17+
runs-on: ${{ matrix.os }}
18+
strategy:
19+
fail-fast: false
20+
matrix:
21+
include:
22+
- os: windows-latest
23+
asset: viralens-windows-x64.zip
24+
- os: macos-latest
25+
asset: viralens-macos-arm64.zip
26+
- os: ubuntu-latest
27+
asset: viralens-linux-x64.zip
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: "3.12" # 各重依赖在 3.12 上 wheel 最全、最稳
34+
35+
- name: Install deps + PyInstaller
36+
run: |
37+
python -m pip install --upgrade pip
38+
python -m pip install -e .
39+
python -m pip install pyinstaller
40+
41+
- name: Build (PyInstaller, onedir)
42+
run: python -m PyInstaller --noconfirm --clean packaging/viralens.spec
43+
44+
- name: Package (Windows)
45+
if: runner.os == 'Windows'
46+
shell: pwsh
47+
run: Compress-Archive -Path dist/viralens -DestinationPath ${{ matrix.asset }}
48+
49+
- name: Package (macOS) # ditto 是 mac 上正确打包 .app 的方式(保留符号链接/权限)
50+
if: runner.os == 'macOS'
51+
run: ditto -c -k --keepParent dist/viralens.app ${{ matrix.asset }}
52+
53+
- name: Package (Linux) # zip 保留可执行位,解压后 ./viralens/viralens 直接能跑
54+
if: runner.os == 'Linux'
55+
run: (cd dist && zip -ry ../${{ matrix.asset }} viralens)
56+
57+
- uses: actions/upload-artifact@v4
58+
with:
59+
name: ${{ matrix.asset }}
60+
path: ${{ matrix.asset }}
61+
if-no-files-found: error
62+
63+
release:
64+
name: Publish Release
65+
needs: build
66+
if: startsWith(github.ref, 'refs/tags/')
67+
runs-on: ubuntu-latest
68+
steps:
69+
- uses: actions/download-artifact@v4
70+
with:
71+
path: artifacts
72+
merge-multiple: true
73+
74+
- name: Create GitHub Release
75+
uses: softprops/action-gh-release@v2
76+
with:
77+
files: artifacts/*.zip
78+
generate_release_notes: true
79+
body: |
80+
## viralens 桌面版下载
81+
82+
按你的系统选一个,**下载 → 解压 → 双击即用**,无需自己装 Python。
83+
84+
| 系统 | 文件 | 怎么打开 |
85+
|---|---|---|
86+
| Windows | `viralens-windows-x64.zip` | 解压后运行文件夹里的 `viralens.exe` |
87+
| macOS (Apple Silicon) | `viralens-macos-arm64.zip` | 解压得 `viralens.app`,见下方 macOS 首次打开说明 |
88+
| Linux (x64) | `viralens-linux-x64.zip` | 解压后运行 `./viralens/viralens` |
89+
90+
> **首次打开提示是正常的 —— 因为这个 app 没有花钱做苹果/微软的代码签名,不是有问题。**
91+
> - **Windows**:若弹出「Windows 已保护你的电脑」,点 **更多信息 → 仍要运行**。
92+
> - **macOS**:先试 **右键 → 打开**;若提示「已损坏 / 无法验证开发者」(macOS 15 Sequoia 起常见),改去 **系统设置 → 隐私与安全性**,在底部点「**仍要打开**」;或在终端执行 `xattr -dr com.apple.quarantine /把/viralens.app/拖进来` 后再双击。
93+
94+
启动后浏览器会自动打开界面。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。
95+
96+
> 可选功能「下视频分析开场镜头 + 配乐」需要自行安装 **ffmpeg**(其余抓取 / 分析 / 报告功能都无需它)。

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,27 @@ because `play_per_day` inflates new uploads) caught two real declines: **a lifes
133133

134134
## Run it on *your* creators
135135

136+
### Easiest: download the app — no Python, no install
137+
138+
Grab the build for your OS from the **[latest release](https://github.com/HarryXin0919/viralens/releases/latest)**,
139+
unzip, and double-click. It opens the same local web UI in your browser — nothing leaves your machine.
140+
141+
| OS | File | Open it |
142+
|---|---|---|
143+
| **Windows** | `viralens-windows-x64.zip` | unzip → run `viralens\viralens.exe` |
144+
| **macOS** (Apple Silicon) | `viralens-macos-arm64.zip` | unzip → `viralens.app` (see first-launch note below) |
145+
| **Linux** (x64) | `viralens-linux-x64.zip` | unzip → run `./viralens/viralens` |
146+
147+
> **The first-launch security prompt is expected** — the app isn't code-signed (that needs paid Apple/Microsoft certificates), it isn't broken.
148+
> - **Windows:** if you see *"Windows protected your PC"*, click **More info → Run anyway**.
149+
> - **macOS:** try **right-click → Open** first; if it says *"damaged / can't verify developer"* (common since macOS 15 Sequoia), go to **System Settings → Privacy & Security** and click **Open Anyway** at the bottom — or run `xattr -dr com.apple.quarantine /path/to/viralens.app` in Terminal, then double-click.
150+
151+
On first launch, paste your **Bilibili SESSDATA** and/or free **YouTube API key** right in the UI.
152+
Your keys and data are stored in your user folder (`%LOCALAPPDATA%\viralens` · `~/Library/Application Support/viralens` · `~/.local/share/viralens`), never inside the app or in git.
153+
The optional *opening-shots + BGM* analysis needs [ffmpeg](https://ffmpeg.org) installed; everything else works without it.
154+
155+
### From source (for developers)
156+
136157
```bash
137158
git clone https://github.com/HarryXin0919/viralens.git
138159
cd viralens
@@ -248,6 +269,7 @@ rate-limit · small *n* is reported as *"weak signal,"* never dressed up as proo
248269
- [x] Cross-language extension to English YouTube (Entertainment-YT, 4 creators) — done; held without a counter-case
249270
- [x] One-command front door — `python viralens.py` (just the data → CSV/JSON) · `--report` (data + full analysis + report)
250271
- [x] Self-contained interactive HTML report — `reports/index.html`
272+
- [x] Downloadable desktop app for Windows / macOS / Linux — no Python install needed ([releases](https://github.com/HarryXin0919/viralens/releases/latest))
251273
- [ ] Per-creator (not keyword-based) signature-form definition
252274
- [ ] Opt-in LLM layer for qualitative "why this form works" summaries
253275

@@ -288,8 +310,13 @@ viralens 是一个**取数 + 分析**的开源小工具。在一个配置文件
288310
附带还能做:**分区基准**(把你放进同区"典型创作者"里定位)和**疲态检测**(只用满 30 天的成熟
289311
视频总播放判断你在涨还是在跌,已抓到生活区、美食区各一例真实下滑)。
290312

291-
跑法见上方 **Run it on your creators**:改 `scripts/creators.py` 填你想看的任意 B站 / YouTube 创作者,
292-
然后 `python scripts/viralens.py`(只要数据)或 `python scripts/viralens.py --report`(数据 + 分析)。
313+
**怎么用 —— 最省事:下载桌面 app。****[Releases 页](https://github.com/HarryXin0919/viralens/releases/latest)**
314+
按系统下载(Windows / macOS / Linux),解压双击即用,**无需自己装 Python**;启动后浏览器自动打开界面,
315+
在里面填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可。你的密钥与数据存在本机用户目录,绝不进 app 包、也不进 git。
316+
317+
想从源码跑(开发者):见上方 **Run it on your creators** —— 改 `scripts/creators.py` 填你想看的任意 B站 / YouTube 创作者,
318+
然后 `python scripts/viralens.py`(只要数据)或 `python scripts/viralens.py --report`(数据 + 分析);
319+
也可 `python scripts/app.py` 开本地网页界面。
293320

294321
---
295322

packaging/build-local.ps1

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# viralens · 本地构建 Windows 桌面 app(给开发者验证用;正式三平台包由 GitHub Actions 出)
2+
#
3+
# 用法: powershell -ExecutionPolicy Bypass -File packaging\build-local.ps1
4+
# 产物: dist\viralens\viralens.exe (整个 dist\viralens\ 文件夹即可压缩分发)
5+
#
6+
# 需要本机已装 Python 3.10+(3.12 最稳)。脚本会装好打包所需依赖,再跑 PyInstaller。
7+
$ErrorActionPreference = "Stop"
8+
Set-Location (Split-Path $PSScriptRoot -Parent) # 切到仓库根
9+
10+
# 找 Python:优先 py -3,退而求其次 python
11+
$py = $null
12+
if (Get-Command py -ErrorAction SilentlyContinue) { $py = "py"; $pyArgs = @("-3") }
13+
elseif (Get-Command python -ErrorAction SilentlyContinue) { $py = "python"; $pyArgs = @() }
14+
else { Write-Error "没找到 Python。先装 Python 3.10+ 并勾选 Add to PATH。"; exit 1 }
15+
16+
Write-Host "[*] 用解释器:" -NoNewline; & $py @pyArgs --version
17+
18+
Write-Host "[*] 安装运行依赖(来自 pyproject)+ PyInstaller ..."
19+
& $py @pyArgs -m pip install --upgrade pip
20+
& $py @pyArgs -m pip install -e .
21+
& $py @pyArgs -m pip install pyinstaller
22+
23+
Write-Host "[*] 打包(onedir)..."
24+
& $py @pyArgs -m PyInstaller --noconfirm --clean packaging/viralens.spec
25+
26+
$exe = Join-Path (Get-Location) "dist\viralens\viralens.exe"
27+
if (Test-Path $exe) {
28+
Write-Host "[OK] 构建完成 -> $exe"
29+
Write-Host " 双击 viralens.exe 即可启动;整个 dist\viralens\ 文件夹打包(zip)就能分发。"
30+
} else {
31+
Write-Error "构建结束但没找到 $exe —— 看上面的 PyInstaller 日志。"
32+
exit 1
33+
}

packaging/viralens.spec

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
"""
3+
PyInstaller 打包配方:把 viralens 连同一个真正的 Python 解释器冻进一个文件夹,
4+
用户无需自己装 Python / pip 依赖,双击即用。三平台(Win/Mac/Linux)同一份 spec。
5+
6+
本地构建(Windows): py -m PyInstaller --noconfirm --clean packaging/viralens.spec
7+
产物: dist/viralens/viralens(.exe) ← onedir,整个文件夹打包分发
8+
9+
为什么用 onedir 而不是 onefile:流水线一次运行会让 app 自己重新拉起 ~6 次
10+
(fetch → 各分析步骤)。onefile 每次启动都要把上百 MB 解压到临时目录,会非常慢;
11+
onedir 直接就地运行,子步骤秒起。
12+
"""
13+
import os
14+
import sys
15+
from PyInstaller.utils.hooks import collect_all
16+
17+
REPO = os.path.dirname(SPECPATH) # SPECPATH 由 PyInstaller 注入 = packaging/
18+
SCRIPTS = os.path.join(REPO, "scripts")
19+
ENTRY = os.path.join(SPECPATH, "viralens_app.py")
20+
ICON = os.path.join(SPECPATH, "icon.ico") # 可选;不存在就不用
21+
22+
datas, binaries, hiddenimports = [], [], []
23+
24+
# —— 重依赖:连子模块 + 数据文件(jieba 词典、bilibili_api 资源等)一起收 ——
25+
for pkg in ("bilibili_api", "aiohttp", "jieba"):
26+
d, b, h = collect_all(pkg)
27+
datas += d
28+
binaries += b
29+
hiddenimports += h
30+
31+
# matplotlib / numpy / Pillow 自带 PyInstaller hook,会自动带数据;这里补 Agg 后端保险
32+
hiddenimports += ["matplotlib.backends.backend_agg", "numpy", "PIL"]
33+
34+
# —— 项目自己的脚本 ——
35+
# app 懒加载它们、viralens 通过 `--vl-exec <模块名>` 用 runpy 跑它们,
36+
# 静态分析有可能看不全,这里全部显式声明,确保都被冻进去。
37+
hiddenimports += [
38+
"runtime", "app", "viralens",
39+
"fetch_multi", "fetch_bilibili", "fetch_youtube",
40+
"compare_form", "creator_profile", "scan_signals", "charts", "export_data",
41+
"diagnose", "analyze_video", "import_private",
42+
"creators", "features", "benchmarks",
43+
"classify_and_stats", "comments", "compare_meme", "fetch_covers",
44+
"fetch_videos", "resolve_creators", "subtitle",
45+
]
46+
47+
# —— 只读资源:网页界面 + 配置模板 —— 放进打包根目录,app.py 用 runtime.ASSET_DIR 找它们 ——
48+
datas += [
49+
(os.path.join(SCRIPTS, "gui.html"), "."),
50+
(os.path.join(SCRIPTS, "diagnose.html"), "."),
51+
(os.path.join(SCRIPTS, "config_local.example.py"), "."),
52+
]
53+
54+
a = Analysis(
55+
[ENTRY],
56+
pathex=[SCRIPTS], # 让 import runtime / app / 各脚本 找得到
57+
binaries=binaries,
58+
datas=datas,
59+
hiddenimports=hiddenimports,
60+
hookspath=[],
61+
runtime_hooks=[],
62+
# yt_dlp 体积巨大且只服务于「下 YouTube 视频开头」这个可选功能(缺了会优雅降级);
63+
# tkinter 是 GUI 工具包,我们用 matplotlib 的 Agg 后端、用不到它。
64+
# 注意:不要排 unittest/test —— matplotlib→pyparsing.testing 会在导入时 import unittest。
65+
excludes=["yt_dlp", "tkinter"],
66+
noarchive=False,
67+
)
68+
69+
pyz = PYZ(a.pure)
70+
71+
exe = EXE(
72+
pyz,
73+
a.scripts,
74+
[],
75+
exclude_binaries=True,
76+
name="viralens",
77+
debug=False,
78+
bootloader_ignore_signals=False,
79+
strip=False,
80+
upx=False,
81+
console=True, # 保留控制台:子进程 stdout 走管道给界面看进度 + 「关窗即停止」
82+
icon=(ICON if os.path.exists(ICON) else None),
83+
)
84+
85+
coll = COLLECT(
86+
exe,
87+
a.binaries,
88+
a.datas,
89+
strip=False,
90+
upx=False,
91+
name="viralens",
92+
)
93+
94+
# macOS:在 onedir 外再包一层 .app,双击即用(无终端窗口)。
95+
if sys.platform == "darwin":
96+
app_bundle = BUNDLE(
97+
coll,
98+
name="viralens.app",
99+
icon=(ICON if os.path.exists(ICON) else None),
100+
bundle_identifier="dev.harryxin.viralens",
101+
info_plist={
102+
"CFBundleName": "viralens",
103+
"CFBundleDisplayName": "viralens",
104+
"NSHighResolutionCapable": True,
105+
"LSBackgroundOnly": False,
106+
},
107+
)

packaging/viralens_app.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""
2+
viralens · 打包成桌面 app 时的入口(被 PyInstaller 冻结,见 viralens.spec)。
3+
4+
两种角色,靠命令行第一个参数区分:
5+
· 正常双击启动 → 起本地网页界面(app.main()),浏览器自动打开。
6+
· 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:这是流水线某一步,
7+
由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出。
8+
(源码模式下这一步等价于 `python scripts/fetch_multi.py --force`。)
9+
10+
为什么要这样:整个工具是一串脚本用 subprocess 互相调起来的。打包后 sys.executable
11+
变成 app 自己而不是 Python,所以让 app 自己充当「Python」——带上 --vl-exec 再跑一遍。
12+
"""
13+
import runtime
14+
15+
runtime.bootstrap()
16+
runtime.dispatch_if_worker() # 若本进程是 --vl-exec 子步骤:跑完即退出,不会往下走
17+
18+
import app
19+
app.main()

scripts/analyze_video.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import json
2525
import os
2626
import re
27+
import shutil
2728
import subprocess
2829
import sys
2930
import tempfile
@@ -33,9 +34,7 @@
3334
if hasattr(sys.stdout, "reconfigure"):
3435
sys.stdout.reconfigure(encoding="utf-8")
3536

36-
HERE = Path(__file__).parent
37-
DATA = HERE.parent / "data"
38-
CLIPS = DATA / "clips"
37+
from runtime import DATA, CLIPS # 源码=仓库/data,打包成 app 时=用户数据目录
3938

4039
CLIP_SECONDS = 45 # 只下开头这么多秒
4140
SCENE_THRESH = 0.35 # 场景切换灵敏度(越低越敏感)
@@ -113,6 +112,22 @@ def _proxy():
113112
return ""
114113

115114

115+
def _ffmpeg_available():
116+
"""这个『下视频分析开场+配乐』功能依赖 ffmpeg/ffprobe;其余功能都不需要。"""
117+
return bool(shutil.which("ffmpeg") and shutil.which("ffprobe"))
118+
119+
120+
def _ffmpeg_missing_result(alias, vid):
121+
tip = ("Windows: winget install Gyan.FFmpeg(或 ffmpeg.org 下载后加进 PATH)\n"
122+
" macOS: brew install ffmpeg\n"
123+
" Linux: sudo apt install ffmpeg")
124+
return {"ok": False, "stage": "ffmpeg", "alias": alias, "vid": vid,
125+
"error": "没检测到 ffmpeg —— 只有这个『下视频分析开场镜头+配乐』功能需要它。",
126+
"hint": {"zh": "抓取 / 分析 / 报告等其余功能都不需要 ffmpeg。装好后重试:\n " + tip,
127+
"en": "Everything else (fetch / analyze / report) works without ffmpeg. "
128+
"Install it and retry:\n " + tip}}
129+
130+
116131
def _ffmpeg_fetch(url, out, dur, referer="https://www.bilibili.com/"):
117132
"""ffmpeg 从远程流地址抓开头 dur 秒到 out。直连、带 Referer(B 站 CDN 必须)。"""
118133
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error",
@@ -391,6 +406,10 @@ def analyze(alias, vid, force=False):
391406
except Exception:
392407
pass
393408

409+
# 缓存没命中才需要真去下视频 —— 这一步(且仅这一步)依赖 ffmpeg。缺了就优雅提示,不崩。
410+
if not _ffmpeg_available():
411+
return _ffmpeg_missing_result(alias, real_vid)
412+
394413
with tempfile.TemporaryDirectory(prefix="viralens_clip_") as wd:
395414
try:
396415
video_path, audio_path = _download(v, wd)

0 commit comments

Comments
 (0)