From 2fce5e8b2184b34c112375e1926ae70e80ab890c Mon Sep 17 00:00:00 2001 From: Harry Alexander Xin Date: Mon, 1 Jun 2026 19:32:15 +0800 Subject: [PATCH 1/4] 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) --- .github/workflows/release.yml | 96 +++++++++++++++++++++++++++++ README.md | 31 +++++++++- packaging/build-local.ps1 | 33 ++++++++++ packaging/viralens.spec | 107 +++++++++++++++++++++++++++++++++ packaging/viralens_app.py | 19 ++++++ scripts/analyze_video.py | 25 +++++++- scripts/app.py | 32 +++++----- scripts/charts.py | 4 +- scripts/classify_and_stats.py | 2 +- scripts/comments.py | 2 +- scripts/compare_form.py | 2 +- scripts/compare_meme.py | 2 +- scripts/creator_profile.py | 2 +- scripts/diagnose.py | 2 +- scripts/export_data.py | 2 +- scripts/fetch_covers.py | 2 +- scripts/fetch_multi.py | 3 +- scripts/fetch_videos.py | 3 +- scripts/import_private.py | 3 +- scripts/runtime.py | 110 ++++++++++++++++++++++++++++++++++ scripts/scan_signals.py | 2 +- scripts/subtitle.py | 2 +- scripts/viralens.py | 11 ++-- webui.bat | 40 +++++++++++++ webui.sh | 34 +++++++++++ 25 files changed, 529 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/build-local.ps1 create mode 100644 packaging/viralens.spec create mode 100644 packaging/viralens_app.py create mode 100644 scripts/runtime.py create mode 100644 webui.bat create mode 100644 webui.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dfb27ff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +# 打一个 v* 版本标签(例:git tag v0.2.0 && git push --tags)就会: +# 在 Windows / macOS / Linux 各构建一份桌面 app,压缩,发布到一个 GitHub Release。 +# 也可在 Actions 页手动触发(workflow_dispatch)只构建、不发布。 +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write # 发布 Release 需要写权限 + +jobs: + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + asset: viralens-windows-x64.zip + - os: macos-latest + asset: viralens-macos-arm64.zip + - os: ubuntu-latest + asset: viralens-linux-x64.zip + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" # 各重依赖在 3.12 上 wheel 最全、最稳 + + - name: Install deps + PyInstaller + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install pyinstaller + + - name: Build (PyInstaller, onedir) + run: python -m PyInstaller --noconfirm --clean packaging/viralens.spec + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Compress-Archive -Path dist/viralens -DestinationPath ${{ matrix.asset }} + + - name: Package (macOS) # ditto 是 mac 上正确打包 .app 的方式(保留符号链接/权限) + if: runner.os == 'macOS' + run: ditto -c -k --keepParent dist/viralens.app ${{ matrix.asset }} + + - name: Package (Linux) # zip 保留可执行位,解压后 ./viralens/viralens 直接能跑 + if: runner.os == 'Linux' + run: (cd dist && zip -ry ../${{ matrix.asset }} viralens) + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} + if-no-files-found: error + + release: + name: Publish Release + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/*.zip + generate_release_notes: true + body: | + ## viralens 桌面版下载 + + 按你的系统选一个,**下载 → 解压 → 双击即用**,无需自己装 Python。 + + | 系统 | 文件 | 怎么打开 | + |---|---|---| + | Windows | `viralens-windows-x64.zip` | 解压后运行文件夹里的 `viralens.exe` | + | macOS (Apple Silicon) | `viralens-macos-arm64.zip` | 解压得 `viralens.app`,见下方 macOS 首次打开说明 | + | Linux (x64) | `viralens-linux-x64.zip` | 解压后运行 `./viralens/viralens` | + + > **首次打开提示是正常的 —— 因为这个 app 没有花钱做苹果/微软的代码签名,不是有问题。** + > - **Windows**:若弹出「Windows 已保护你的电脑」,点 **更多信息 → 仍要运行**。 + > - **macOS**:先试 **右键 → 打开**;若提示「已损坏 / 无法验证开发者」(macOS 15 Sequoia 起常见),改去 **系统设置 → 隐私与安全性**,在底部点「**仍要打开**」;或在终端执行 `xattr -dr com.apple.quarantine /把/viralens.app/拖进来` 后再双击。 + + 启动后浏览器会自动打开界面。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。 + + > 可选功能「下视频分析开场镜头 + 配乐」需要自行安装 **ffmpeg**(其余抓取 / 分析 / 报告功能都无需它)。 diff --git a/README.md b/README.md index 1c64001..d6948cd 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,27 @@ because `play_per_day` inflates new uploads) caught two real declines: **a lifes ## Run it on *your* creators +### Easiest: download the app — no Python, no install + +Grab the build for your OS from the **[latest release](https://github.com/HarryXin0919/viralens/releases/latest)**, +unzip, and double-click. It opens the same local web UI in your browser — nothing leaves your machine. + +| OS | File | Open it | +|---|---|---| +| **Windows** | `viralens-windows-x64.zip` | unzip → run `viralens\viralens.exe` | +| **macOS** (Apple Silicon) | `viralens-macos-arm64.zip` | unzip → `viralens.app` (see first-launch note below) | +| **Linux** (x64) | `viralens-linux-x64.zip` | unzip → run `./viralens/viralens` | + +> **The first-launch security prompt is expected** — the app isn't code-signed (that needs paid Apple/Microsoft certificates), it isn't broken. +> - **Windows:** if you see *"Windows protected your PC"*, click **More info → Run anyway**. +> - **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. + +On first launch, paste your **Bilibili SESSDATA** and/or free **YouTube API key** right in the UI. +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. +The optional *opening-shots + BGM* analysis needs [ffmpeg](https://ffmpeg.org) installed; everything else works without it. + +### From source (for developers) + ```bash git clone https://github.com/HarryXin0919/viralens.git cd viralens @@ -248,6 +269,7 @@ rate-limit · small *n* is reported as *"weak signal,"* never dressed up as proo - [x] Cross-language extension to English YouTube (Entertainment-YT, 4 creators) — done; held without a counter-case - [x] One-command front door — `python viralens.py` (just the data → CSV/JSON) · `--report` (data + full analysis + report) - [x] Self-contained interactive HTML report — `reports/index.html` +- [x] Downloadable desktop app for Windows / macOS / Linux — no Python install needed ([releases](https://github.com/HarryXin0919/viralens/releases/latest)) - [ ] Per-creator (not keyword-based) signature-form definition - [ ] Opt-in LLM layer for qualitative "why this form works" summaries @@ -288,8 +310,13 @@ viralens 是一个**取数 + 分析**的开源小工具。在一个配置文件 附带还能做:**分区基准**(把你放进同区"典型创作者"里定位)和**疲态检测**(只用满 30 天的成熟 视频总播放判断你在涨还是在跌,已抓到生活区、美食区各一例真实下滑)。 -跑法见上方 **Run it on your creators**:改 `scripts/creators.py` 填你想看的任意 B站 / YouTube 创作者, -然后 `python scripts/viralens.py`(只要数据)或 `python scripts/viralens.py --report`(数据 + 分析)。 +**怎么用 —— 最省事:下载桌面 app。** 到 **[Releases 页](https://github.com/HarryXin0919/viralens/releases/latest)** +按系统下载(Windows / macOS / Linux),解压双击即用,**无需自己装 Python**;启动后浏览器自动打开界面, +在里面填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可。你的密钥与数据存在本机用户目录,绝不进 app 包、也不进 git。 + +想从源码跑(开发者):见上方 **Run it on your creators** —— 改 `scripts/creators.py` 填你想看的任意 B站 / YouTube 创作者, +然后 `python scripts/viralens.py`(只要数据)或 `python scripts/viralens.py --report`(数据 + 分析); +也可 `python scripts/app.py` 开本地网页界面。 --- diff --git a/packaging/build-local.ps1 b/packaging/build-local.ps1 new file mode 100644 index 0000000..1b037bf --- /dev/null +++ b/packaging/build-local.ps1 @@ -0,0 +1,33 @@ +# viralens · 本地构建 Windows 桌面 app(给开发者验证用;正式三平台包由 GitHub Actions 出) +# +# 用法: powershell -ExecutionPolicy Bypass -File packaging\build-local.ps1 +# 产物: dist\viralens\viralens.exe (整个 dist\viralens\ 文件夹即可压缩分发) +# +# 需要本机已装 Python 3.10+(3.12 最稳)。脚本会装好打包所需依赖,再跑 PyInstaller。 +$ErrorActionPreference = "Stop" +Set-Location (Split-Path $PSScriptRoot -Parent) # 切到仓库根 + +# 找 Python:优先 py -3,退而求其次 python +$py = $null +if (Get-Command py -ErrorAction SilentlyContinue) { $py = "py"; $pyArgs = @("-3") } +elseif (Get-Command python -ErrorAction SilentlyContinue) { $py = "python"; $pyArgs = @() } +else { Write-Error "没找到 Python。先装 Python 3.10+ 并勾选 Add to PATH。"; exit 1 } + +Write-Host "[*] 用解释器:" -NoNewline; & $py @pyArgs --version + +Write-Host "[*] 安装运行依赖(来自 pyproject)+ PyInstaller ..." +& $py @pyArgs -m pip install --upgrade pip +& $py @pyArgs -m pip install -e . +& $py @pyArgs -m pip install pyinstaller + +Write-Host "[*] 打包(onedir)..." +& $py @pyArgs -m PyInstaller --noconfirm --clean packaging/viralens.spec + +$exe = Join-Path (Get-Location) "dist\viralens\viralens.exe" +if (Test-Path $exe) { + Write-Host "[OK] 构建完成 -> $exe" + Write-Host " 双击 viralens.exe 即可启动;整个 dist\viralens\ 文件夹打包(zip)就能分发。" +} else { + Write-Error "构建结束但没找到 $exe —— 看上面的 PyInstaller 日志。" + exit 1 +} diff --git a/packaging/viralens.spec b/packaging/viralens.spec new file mode 100644 index 0000000..5a3cdc1 --- /dev/null +++ b/packaging/viralens.spec @@ -0,0 +1,107 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller 打包配方:把 viralens 连同一个真正的 Python 解释器冻进一个文件夹, +用户无需自己装 Python / pip 依赖,双击即用。三平台(Win/Mac/Linux)同一份 spec。 + + 本地构建(Windows): py -m PyInstaller --noconfirm --clean packaging/viralens.spec + 产物: dist/viralens/viralens(.exe) ← onedir,整个文件夹打包分发 + +为什么用 onedir 而不是 onefile:流水线一次运行会让 app 自己重新拉起 ~6 次 +(fetch → 各分析步骤)。onefile 每次启动都要把上百 MB 解压到临时目录,会非常慢; +onedir 直接就地运行,子步骤秒起。 +""" +import os +import sys +from PyInstaller.utils.hooks import collect_all + +REPO = os.path.dirname(SPECPATH) # SPECPATH 由 PyInstaller 注入 = packaging/ +SCRIPTS = os.path.join(REPO, "scripts") +ENTRY = os.path.join(SPECPATH, "viralens_app.py") +ICON = os.path.join(SPECPATH, "icon.ico") # 可选;不存在就不用 + +datas, binaries, hiddenimports = [], [], [] + +# —— 重依赖:连子模块 + 数据文件(jieba 词典、bilibili_api 资源等)一起收 —— +for pkg in ("bilibili_api", "aiohttp", "jieba"): + d, b, h = collect_all(pkg) + datas += d + binaries += b + hiddenimports += h + +# matplotlib / numpy / Pillow 自带 PyInstaller hook,会自动带数据;这里补 Agg 后端保险 +hiddenimports += ["matplotlib.backends.backend_agg", "numpy", "PIL"] + +# —— 项目自己的脚本 —— +# app 懒加载它们、viralens 通过 `--vl-exec <模块名>` 用 runpy 跑它们, +# 静态分析有可能看不全,这里全部显式声明,确保都被冻进去。 +hiddenimports += [ + "runtime", "app", "viralens", + "fetch_multi", "fetch_bilibili", "fetch_youtube", + "compare_form", "creator_profile", "scan_signals", "charts", "export_data", + "diagnose", "analyze_video", "import_private", + "creators", "features", "benchmarks", + "classify_and_stats", "comments", "compare_meme", "fetch_covers", + "fetch_videos", "resolve_creators", "subtitle", +] + +# —— 只读资源:网页界面 + 配置模板 —— 放进打包根目录,app.py 用 runtime.ASSET_DIR 找它们 —— +datas += [ + (os.path.join(SCRIPTS, "gui.html"), "."), + (os.path.join(SCRIPTS, "diagnose.html"), "."), + (os.path.join(SCRIPTS, "config_local.example.py"), "."), +] + +a = Analysis( + [ENTRY], + pathex=[SCRIPTS], # 让 import runtime / app / 各脚本 找得到 + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + runtime_hooks=[], + # yt_dlp 体积巨大且只服务于「下 YouTube 视频开头」这个可选功能(缺了会优雅降级); + # tkinter 是 GUI 工具包,我们用 matplotlib 的 Agg 后端、用不到它。 + # 注意:不要排 unittest/test —— matplotlib→pyparsing.testing 会在导入时 import unittest。 + excludes=["yt_dlp", "tkinter"], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="viralens", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, # 保留控制台:子进程 stdout 走管道给界面看进度 + 「关窗即停止」 + icon=(ICON if os.path.exists(ICON) else None), +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="viralens", +) + +# macOS:在 onedir 外再包一层 .app,双击即用(无终端窗口)。 +if sys.platform == "darwin": + app_bundle = BUNDLE( + coll, + name="viralens.app", + icon=(ICON if os.path.exists(ICON) else None), + bundle_identifier="dev.harryxin.viralens", + info_plist={ + "CFBundleName": "viralens", + "CFBundleDisplayName": "viralens", + "NSHighResolutionCapable": True, + "LSBackgroundOnly": False, + }, + ) diff --git a/packaging/viralens_app.py b/packaging/viralens_app.py new file mode 100644 index 0000000..05727c6 --- /dev/null +++ b/packaging/viralens_app.py @@ -0,0 +1,19 @@ +""" +viralens · 打包成桌面 app 时的入口(被 PyInstaller 冻结,见 viralens.spec)。 + +两种角色,靠命令行第一个参数区分: + · 正常双击启动 → 起本地网页界面(app.main()),浏览器自动打开。 + · 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:这是流水线某一步, + 由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出。 + (源码模式下这一步等价于 `python scripts/fetch_multi.py --force`。) + +为什么要这样:整个工具是一串脚本用 subprocess 互相调起来的。打包后 sys.executable +变成 app 自己而不是 Python,所以让 app 自己充当「Python」——带上 --vl-exec 再跑一遍。 +""" +import runtime + +runtime.bootstrap() +runtime.dispatch_if_worker() # 若本进程是 --vl-exec 子步骤:跑完即退出,不会往下走 + +import app +app.main() diff --git a/scripts/analyze_video.py b/scripts/analyze_video.py index 41bb3e8..c6659d1 100644 --- a/scripts/analyze_video.py +++ b/scripts/analyze_video.py @@ -24,6 +24,7 @@ import json import os import re +import shutil import subprocess import sys import tempfile @@ -33,9 +34,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -HERE = Path(__file__).parent -DATA = HERE.parent / "data" -CLIPS = DATA / "clips" +from runtime import DATA, CLIPS # 源码=仓库/data,打包成 app 时=用户数据目录 CLIP_SECONDS = 45 # 只下开头这么多秒 SCENE_THRESH = 0.35 # 场景切换灵敏度(越低越敏感) @@ -113,6 +112,22 @@ def _proxy(): return "" +def _ffmpeg_available(): + """这个『下视频分析开场+配乐』功能依赖 ffmpeg/ffprobe;其余功能都不需要。""" + return bool(shutil.which("ffmpeg") and shutil.which("ffprobe")) + + +def _ffmpeg_missing_result(alias, vid): + tip = ("Windows: winget install Gyan.FFmpeg(或 ffmpeg.org 下载后加进 PATH)\n" + " macOS: brew install ffmpeg\n" + " Linux: sudo apt install ffmpeg") + return {"ok": False, "stage": "ffmpeg", "alias": alias, "vid": vid, + "error": "没检测到 ffmpeg —— 只有这个『下视频分析开场镜头+配乐』功能需要它。", + "hint": {"zh": "抓取 / 分析 / 报告等其余功能都不需要 ffmpeg。装好后重试:\n " + tip, + "en": "Everything else (fetch / analyze / report) works without ffmpeg. " + "Install it and retry:\n " + tip}} + + def _ffmpeg_fetch(url, out, dur, referer="https://www.bilibili.com/"): """ffmpeg 从远程流地址抓开头 dur 秒到 out。直连、带 Referer(B 站 CDN 必须)。""" cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", @@ -391,6 +406,10 @@ def analyze(alias, vid, force=False): except Exception: pass + # 缓存没命中才需要真去下视频 —— 这一步(且仅这一步)依赖 ffmpeg。缺了就优雅提示,不崩。 + if not _ffmpeg_available(): + return _ffmpeg_missing_result(alias, real_vid) + with tempfile.TemporaryDirectory(prefix="viralens_clip_") as wd: try: video_path, audio_path = _download(v, wd) diff --git a/scripts/app.py b/scripts/app.py index 93bbfc6..c3ff503 100644 --- a/scripts/app.py +++ b/scripts/app.py @@ -24,17 +24,18 @@ from pathlib import Path from urllib.parse import urlparse, parse_qs +import runtime # 收口「源码跑 vs 打包成 app 跑」的路径/子进程差异 + if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -HERE = Path(__file__).parent # scripts/ -ROOT = HERE.parent # 仓库根 -DATA = ROOT / "data" -REPORTS = ROOT / "reports" -GUI = HERE / "gui.html" -CONFIG = HERE / "config_local.py" +HERE = runtime.ASSET_DIR # scripts/(源码)或打包资源目录(app),只读 +DATA = runtime.DATA # 可写:源码=仓库/data,app=用户数据目录 +REPORTS = runtime.REPORTS +GUI = runtime.ASSET_DIR / "gui.html" +CONFIG = runtime.CONFIG # 密钥文件:源码=scripts/,app=用户数据目录 -sys.path.insert(0, str(HERE)) # 让我们能 import creators / config_local +# runtime 已把 ASSET_DIR / USER_DIR 放进 sys.path —— import creators / config_local 即可用 # —— 跑流水线时的实时进度(后台线程写,/api/progress 读)—— PROGRESS = {"running": False, "lines": [], "returncode": None, "mode": ""} @@ -166,14 +167,15 @@ def diag_clip(alias, vid): # ——————————————————————— 跑流水线(后台线程) ——————————————————————— def run_pipeline(mode, no_fetch, force, platforms=None): - cmd = [sys.executable, str(HERE / "viralens.py")] + extra = [] if mode == "report": - cmd.append("--report") + extra.append("--report") if no_fetch: - cmd.append("--no-fetch") + extra.append("--no-fetch") if force: - cmd.append("--force") - shown = "viralens.py " + " ".join(cmd[2:]) + extra.append("--force") + cmd = runtime.worker_cmd("viralens.py", extra) # 源码:[py, viralens.py];app:[自己, --vl-exec, viralens] + shown = "viralens.py " + " ".join(extra) # 只抓用户在界面勾选的平台(通过环境变量传给 fetch_multi.py) env = os.environ.copy() if platforms: @@ -181,9 +183,11 @@ def run_pipeline(mode, no_fetch, force, platforms=None): with LOCK: PROGRESS.update(running=True, lines=[f"$ {shown}"], returncode=None, mode=mode) try: + # 打包模式 cwd 用可写的用户目录(程序目录只读);源码模式保持 scripts/(行为不变) + cwd = str(runtime.USER_DIR) if runtime.FROZEN else str(HERE) p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", - bufsize=1, cwd=str(HERE), env=env) + bufsize=1, cwd=cwd, env=env) for line in p.stdout: with LOCK: PROGRESS["lines"].append(line.rstrip("\n")) @@ -223,7 +227,7 @@ def do_import(path): for a, vs in groups.items(): (DATA / f"{a}_videos.json").write_text( json.dumps(vs, ensure_ascii=False, indent=2), encoding="utf-8") - subprocess.run([sys.executable, str(HERE / "export_data.py")]) + subprocess.run(runtime.worker_cmd("export_data.py")) return {"ok": True, "creators": len(groups), "videos": len(recs)} diff --git a/scripts/charts.py b/scripts/charts.py index decfdae..afb6a89 100644 --- a/scripts/charts.py +++ b/scripts/charts.py @@ -29,9 +29,7 @@ ] plt.rcParams["axes.unicode_minus"] = False -ROOT = Path(__file__).parent.parent -DATA = ROOT / "data" -IMG = ROOT / "reports" / "img" +from runtime import DATA, IMG, USER_DIR as ROOT # 源码=仓库,打包成 app 时=用户数据目录(ROOT 仅用于打印相对路径) IMG.mkdir(parents=True, exist_ok=True) ACCENT = "#61C4E3" # 项目主色 diff --git a/scripts/classify_and_stats.py b/scripts/classify_and_stats.py index ec9ba43..2ecc88e 100644 --- a/scripts/classify_and_stats.py +++ b/scripts/classify_and_stats.py @@ -15,7 +15,7 @@ from collections import defaultdict from statistics import median, mean -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 IN = DATA / "bidao_videos.json" OUT = DATA / "classified.json" diff --git a/scripts/comments.py b/scripts/comments.py index b81694f..8e20b6f 100644 --- a/scripts/comments.py +++ b/scripts/comments.py @@ -25,7 +25,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # Win 控制台默认 GBK,强制 UTF-8 -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 try: from config_local import SESSDATA except ImportError: diff --git a/scripts/compare_form.py b/scripts/compare_form.py index 022f493..870a8b4 100644 --- a/scripts/compare_form.py +++ b/scripts/compare_form.py @@ -14,7 +14,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 from creators import CREATORS # 偏离核心形式的粗标记(宁紧勿松,误判靠人眼复核打印的标题) diff --git a/scripts/compare_meme.py b/scripts/compare_meme.py index d1434c2..d7fff8b 100644 --- a/scripts/compare_meme.py +++ b/scripts/compare_meme.py @@ -27,7 +27,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 try: from config_local import SESSDATA except ImportError: diff --git a/scripts/creator_profile.py b/scripts/creator_profile.py index 3875bdf..0e82c77 100644 --- a/scripts/creator_profile.py +++ b/scripts/creator_profile.py @@ -27,7 +27,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 from creators import CREATORS from features import extract from scan_signals import spearman # 复用秩相关,避免重复实现 diff --git a/scripts/diagnose.py b/scripts/diagnose.py index c24aed8..6728fc3 100644 --- a/scripts/diagnose.py +++ b/scripts/diagnose.py @@ -23,7 +23,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 # —— 懒加载 + 缓存:同一进程里只读一次盘 —— _CACHE = {} diff --git a/scripts/export_data.py b/scripts/export_data.py index 4ebde05..818958b 100644 --- a/scripts/export_data.py +++ b/scripts/export_data.py @@ -18,7 +18,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 # CSV 里放哪些列、按什么顺序(挑人看得懂、Excel 排序有用的;长描述留在 JSON 里不塞 CSV) CSV_COLS = ["creator", "platform", "zone", "title", "play", "comment", "like", diff --git a/scripts/fetch_covers.py b/scripts/fetch_covers.py index 315dbb2..dfc449a 100644 --- a/scripts/fetch_covers.py +++ b/scripts/fetch_covers.py @@ -30,7 +30,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 from creators import CREATORS HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", diff --git a/scripts/fetch_multi.py b/scripts/fetch_multi.py index 8917a03..019451b 100644 --- a/scripts/fetch_multi.py +++ b/scripts/fetch_multi.py @@ -20,8 +20,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -DATA = Path(__file__).parent.parent / "data" -DATA.mkdir(exist_ok=True) +from runtime import DATA # 可写数据目录(源码=仓库/data,app=用户目录;runtime 已自动建好) try: from config_local import SESSDATA diff --git a/scripts/fetch_videos.py b/scripts/fetch_videos.py index a1d9246..6e6aa8b 100644 --- a/scripts/fetch_videos.py +++ b/scripts/fetch_videos.py @@ -30,7 +30,8 @@ from config_local import SESSDATA # ← SESSDATA 统一放 config_local.py(已 gitignore,不进 git) except ImportError: SESSDATA = "" -OUTPUT = Path(__file__).parent.parent / "data" / "bidao_videos.json" +from runtime import DATA +OUTPUT = DATA / "bidao_videos.json" # ============================= diff --git a/scripts/import_private.py b/scripts/import_private.py index aa50959..03c2675 100644 --- a/scripts/import_private.py +++ b/scripts/import_private.py @@ -24,8 +24,7 @@ if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") -HERE = Path(__file__).parent -DATA = HERE.parent / "data" +from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录 PRIV = DATA / "private" diff --git a/scripts/runtime.py b/scripts/runtime.py new file mode 100644 index 0000000..c1c2527 --- /dev/null +++ b/scripts/runtime.py @@ -0,0 +1,110 @@ +""" +viralens · runtime.py —— 一处搞定「从源码跑」和「打包成 app 跑」的差异。 + +为什么需要它:整个工具是一串脚本互相用 subprocess 调起来的(app.py → viralens.py → +fetch_multi.py …),每个脚本都靠 `Path(__file__).parent.parent / "data"` 找数据目录、 +靠 `sys.executable` 找 Python。打包成单个可执行 app 后这两件事都变了: + · sys.executable 变成 app 自己,不再是 Python —— 不能再 `[sys.executable, "xxx.py"]`; + · 程序目录是只读的(尤其 macOS/.app、Linux/AppImage)—— data/ reports/ 不能写在里面。 + +这个模块把这两件事各收口到一个地方: + + · 路径:DATA / REPORTS / IMG / CLIPS / CONFIG —— 源码模式 = 仓库目录(行为和以前一字不差); + 打包模式 = 用户可写目录(Win: %LOCALAPPDATA%\viralens,mac: ~/Library/Application Support/ + viralens,Linux: ~/.local/share/viralens)。 + · 调子脚本:worker_cmd("fetch_multi.py", [...]) 给出正确的命令行;打包模式下子脚本通过 + app 自己用 `--vl-exec <模块名>` 重新拉起(见 dispatch_if_worker)。 + +只用标准库,绝不 import 项目里其它脚本(避免循环依赖)。 +""" +import os +import sys +from pathlib import Path + +# —— 是不是被 PyInstaller 冻结成 app 了 —— +FROZEN = bool(getattr(sys, "frozen", False)) + +# —— 只读资源目录(gui.html / diagnose.html / config 模板就在这)—— +if FROZEN: + ASSET_DIR = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)).resolve() +else: + ASSET_DIR = Path(__file__).resolve().parent # scripts/ +REPO_ROOT = ASSET_DIR.parent # 源码模式下 = 仓库根 + + +def _user_dir() -> Path: + """打包模式下,可写数据放系统约定的「应用数据」目录。""" + name = "viralens" + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") + elif sys.platform == "darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + return Path(base) / name + + +# —— 可写状态目录 —— +# 源码模式:仓库根(data/、reports/ 还在仓库里,和以前完全一样) +# 打包模式:用户应用数据目录(程序目录只读,不能写这里) +USER_DIR = _user_dir() if FROZEN else REPO_ROOT + +DATA = USER_DIR / "data" +REPORTS = USER_DIR / "reports" +IMG = REPORTS / "img" +CLIPS = DATA / "clips" +# 密钥文件:源码模式放 scripts/(和历史一致、能被 import);打包模式放用户目录(可写 + 在 sys.path 上) +CONFIG = (ASSET_DIR / "config_local.py") if not FROZEN else (USER_DIR / "config_local.py") +CONFIG_EXAMPLE = ASSET_DIR / "config_local.example.py" + +_BOOTSTRAPPED = False + + +def bootstrap() -> None: + """建好可写目录;把 ASSET_DIR 和 USER_DIR 放进 sys.path, + 让 `import config_local` / `import creators` / `import creators_local` + 在源码和打包两种模式下都找得到。可重复调用,幂等。""" + global _BOOTSTRAPPED + if _BOOTSTRAPPED: + return + for d in (DATA, REPORTS, IMG): + try: + d.mkdir(parents=True, exist_ok=True) + except Exception: + pass + # USER_DIR 在前:用户的 config_local.py / creators_local.py 覆盖打包内的默认值 + for p in (str(USER_DIR), str(ASSET_DIR)): + if p not in sys.path: + sys.path.insert(0, p) + _BOOTSTRAPPED = True + + +def worker_cmd(script: str, args=None): + """拼出「用本工具自己的解释器跑某个子脚本」的命令行。 + 源码模式: [python, scripts/""" + + +def main(): + form = load("cross_creator_form.json") + profile = load("creator_profile.json") + scan = load("signal_scan.json") + videos = load("all_videos.json") or [] + try: + from features import BINARY_LABELS, NUMERIC_LABELS + except Exception: + BINARY_LABELS, NUMERIC_LABELS = {}, {} + + n_creators = len({v.get("creator") for v in videos}) if videos else len(form or []) + parts = [HEAD] + parts.append( + f'

viralens 报告

' + f'
{n_creators} 位创作者 · {len(videos)} 条视频 · ' + f'{time.strftime("%Y-%m-%d %H:%M")}
' + ) + body = [section_headline(form), section_charts(), + section_creators(profile, form), section_signals(scan, BINARY_LABELS, NUMERIC_LABELS), + section_table(videos)] + body = [b for b in body if b] + if not body: + body = ['

还没有数据

' + '

先在界面里「抓取并分析」,或命令行跑 python viralens.py --report,再生成报告。

'] + parts.extend(body) + parts.append(FOOT_TMPL.replace("__TS__", time.strftime("%Y-%m-%d %H:%M"))) + + REPORTS.mkdir(parents=True, exist_ok=True) + out = REPORTS / "index.html" + out.write_text("\n".join(parts), encoding="utf-8") + print(f"✅ 已写交互报告 → {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/viralens.py b/scripts/viralens.py index d1dd650..1ad0f54 100644 --- a/scripts/viralens.py +++ b/scripts/viralens.py @@ -129,6 +129,7 @@ def main(): run("scan_signals.py") # 多维信号扫描(哪些杠杆通用、哪些因人而异) run("charts.py", optional=True) # README 配图(要 matplotlib;缺了不致命) run("export_data.py") # 顺手也整理一份干净数据出来 + run("build_report.py") # 汇总成单个自包含 reports/index.html(可离线打开/转发) print("\n" + "=" * 60) print("✅ 全跑完了。") print(f" 交互报告 → {REPORT}") From 45b9f38c6e7aff747c4ee90dcc793c94516660a7 Mon Sep 17 00:00:00 2001 From: Harry Alexander Xin Date: Mon, 1 Jun 2026 22:00:26 +0800 Subject: [PATCH 3/4] Native app window via pywebview (no terminal, no browser) The packaged app now opens in its own window instead of popping a console and a browser tab. Built windowed (console=False); the entry starts the local server then opens a pywebview window, falling back to the browser if no system WebView is available (e.g. Linux without WebKitGTK). - viralens_app.py: stdout-rebind shim so windowed --vl-exec child processes still stream output to the parent's captured pipe (devnull for the windowless main process); dispatch workers before opening any window. - app.py: add non-blocking start_server(); main() now blocks on an Event. - spec: console=False; bundle pywebview when present, else browser-fallback. - pyproject [gui] extra = pywebview; CI installs it on Windows/macOS only. - Verified on Windows: native WebView2 window renders, child stdout streams. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 8 ++++- README.md | 3 +- packaging/build-local.ps1 | 4 +-- packaging/viralens.spec | 13 +++++++- packaging/viralens_app.py | 63 ++++++++++++++++++++++++++++++----- pyproject.toml | 4 +++ scripts/app.py | 13 ++++++-- 7 files changed, 92 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfb27ff..10629c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,12 @@ jobs: python -m pip install -e . python -m pip install pyinstaller + # 原生窗口后端只在 Windows/macOS 装(用系统自带 webview)。Linux 的 GTK webview + # 打包困难,故不装 —— Linux 包会自动回退到「开浏览器」(见 viralens_app.py)。 + - name: Install native-window backend (Windows/macOS only) + if: runner.os != 'Linux' + run: python -m pip install pywebview + - name: Build (PyInstaller, onedir) run: python -m PyInstaller --noconfirm --clean packaging/viralens.spec @@ -91,6 +97,6 @@ jobs: > - **Windows**:若弹出「Windows 已保护你的电脑」,点 **更多信息 → 仍要运行**。 > - **macOS**:先试 **右键 → 打开**;若提示「已损坏 / 无法验证开发者」(macOS 15 Sequoia 起常见),改去 **系统设置 → 隐私与安全性**,在底部点「**仍要打开**」;或在终端执行 `xattr -dr com.apple.quarantine /把/viralens.app/拖进来` 后再双击。 - 启动后浏览器会自动打开界面。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。 + 启动后会直接弹出 **viralens 应用窗口**(没有终端、没有浏览器标签页;没装系统 WebView 的部分 Linux 环境会自动回退到用浏览器打开)。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。 > 可选功能「下视频分析开场镜头 + 配乐」需要自行安装 **ffmpeg**(其余抓取 / 分析 / 报告功能都无需它)。 diff --git a/README.md b/README.md index d6948cd..20b2422 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ because `play_per_day` inflates new uploads) caught two real declines: **a lifes ### Easiest: download the app — no Python, no install Grab the build for your OS from the **[latest release](https://github.com/HarryXin0919/viralens/releases/latest)**, -unzip, and double-click. It opens the same local web UI in your browser — nothing leaves your machine. +unzip, and double-click. It opens in **its own app window** — no terminal, no browser tab; nothing leaves your machine. +(On a Linux box without a system WebView, it falls back to opening the UI in your browser.) | OS | File | Open it | |---|---|---| diff --git a/packaging/build-local.ps1 b/packaging/build-local.ps1 index 1b037bf..e926539 100644 --- a/packaging/build-local.ps1 +++ b/packaging/build-local.ps1 @@ -15,9 +15,9 @@ else { Write-Error "没找到 Python。先装 Python 3.10+ 并勾选 Add to PATH Write-Host "[*] 用解释器:" -NoNewline; & $py @pyArgs --version -Write-Host "[*] 安装运行依赖(来自 pyproject)+ PyInstaller ..." +Write-Host "[*] 安装运行依赖(来自 pyproject)+ 原生窗口后端 + PyInstaller ..." & $py @pyArgs -m pip install --upgrade pip -& $py @pyArgs -m pip install -e . +& $py @pyArgs -m pip install -e ".[gui]" # [gui] = pywebview,双击弹原生窗口 & $py @pyArgs -m pip install pyinstaller Write-Host "[*] 打包(onedir)..." diff --git a/packaging/viralens.spec b/packaging/viralens.spec index a32c143..9858320 100644 --- a/packaging/viralens.spec +++ b/packaging/viralens.spec @@ -31,6 +31,17 @@ for pkg in ("bilibili_api", "aiohttp", "jieba"): # matplotlib / numpy / Pillow 自带 PyInstaller hook,会自动带数据;这里补 Agg 后端保险 hiddenimports += ["matplotlib.backends.backend_agg", "numpy", "PIL"] +# 原生窗口后端(可选):装了 pywebview 就连同其平台后端一起收进来,实现「双击弹原生窗口」; +# 没装(比如 Linux 不打包 webview)则打成「开浏览器」回退版 —— 入口 viralens_app.py 会自动判断。 +try: + import webview # noqa: F401 + _d, _b, _h = collect_all("webview") + datas += _d + binaries += _b + hiddenimports += _h +except Exception: + pass + # —— 项目自己的脚本 —— # app 懒加载它们、viralens 通过 `--vl-exec <模块名>` 用 runpy 跑它们, # 静态分析有可能看不全,这里全部显式声明,确保都被冻进去。 @@ -78,7 +89,7 @@ exe = EXE( bootloader_ignore_signals=False, strip=False, upx=False, - console=True, # 保留控制台:子进程 stdout 走管道给界面看进度 + 「关窗即停止」 + console=False, # 窗口模式:不弹终端。子进程 stdout 由 viralens_app._ensure_std() 接回管道 icon=(ICON if os.path.exists(ICON) else None), ) diff --git a/packaging/viralens_app.py b/packaging/viralens_app.py index 05727c6..69ef9b0 100644 --- a/packaging/viralens_app.py +++ b/packaging/viralens_app.py @@ -1,19 +1,66 @@ """ viralens · 打包成桌面 app 时的入口(被 PyInstaller 冻结,见 viralens.spec)。 +目标:双击直接弹出一个**原生应用窗口**(pywebview)——没有终端、没有浏览器标签页。 +缺原生 webview 运行时(如部分 Linux 没装 WebKitGTK)时,自动回退到「开浏览器」。 + 两种角色,靠命令行第一个参数区分: - · 正常双击启动 → 起本地网页界面(app.main()),浏览器自动打开。 - · 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:这是流水线某一步, - 由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出。 - (源码模式下这一步等价于 `python scripts/fetch_multi.py --force`。) + · 正常双击启动 → 起本地服务器 + 开原生窗口(或回退浏览器)。 + · 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:流水线某一步, + 由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出(不开窗口)。 -为什么要这样:整个工具是一串脚本用 subprocess 互相调起来的。打包后 sys.executable -变成 app 自己而不是 Python,所以让 app 自己充当「Python」——带上 --vl-exec 再跑一遍。 +注意:本 app 以「窗口模式」(console=False)打包,没有控制台。下面 _ensure_std() 负责: + · 子步骤进程(被父进程用管道收 stdout 显示进度)→ 把标准输出接回那条管道; + · 主窗口进程(双击,没有任何 std 句柄)→ 接到 devnull,避免 print 崩溃。 """ +import io +import os +import sys + + +def _ensure_std(): + """窗口模式下 sys.stdout/stderr 可能是 None。子进程的句柄是父进程给的管道(有效)→ + 接回去让进度能被收集;主窗口进程没有有效句柄 → dup 失败,退到 devnull。""" + for name, fd in (("stdout", 1), ("stderr", 2)): + if getattr(sys, name, None) is None: + try: + stream = io.TextIOWrapper(os.fdopen(os.dup(fd), "wb"), + encoding="utf-8", errors="replace", line_buffering=True) + except Exception: + stream = open(os.devnull, "w", encoding="utf-8", errors="replace") + setattr(sys, name, stream) + + +_ensure_std() + import runtime runtime.bootstrap() -runtime.dispatch_if_worker() # 若本进程是 --vl-exec 子步骤:跑完即退出,不会往下走 +runtime.dispatch_if_worker() # 若是 --vl-exec 子步骤:跑完即退出,绝不往下走(不开窗口) import app -app.main() + +# —— 主进程:起服务器,开原生窗口;开不出来就回退浏览器 —— +srv, url = app.start_server() + +_opened = False +try: + import webview + webview.create_window("viralens", url, width=1180, height=820, min_size=(900, 600)) + webview.start() # 阻塞,直到用户关掉窗口 + _opened = True +except Exception: + _opened = False + +if not _opened: + # 没有可用的原生 webview(如 Linux 缺 WebKitGTK)→ 开浏览器并挂住进程 + import threading + import webbrowser + try: + webbrowser.open(url) + except Exception: + pass + try: + threading.Event().wait() + except KeyboardInterrupt: + pass diff --git a/pyproject.toml b/pyproject.toml index 3c01da3..9cee7d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,10 @@ dependencies = [ "numpy>=1.23.0", ] +# 仅在「打包成桌面 app」时需要:原生窗口后端。源码 CLI / 网页界面都不依赖它。 +[project.optional-dependencies] +gui = ["pywebview>=5.0"] + [project.urls] Homepage = "https://github.com/HarryXin0919/viralens" Repository = "https://github.com/HarryXin0919/viralens" diff --git a/scripts/app.py b/scripts/app.py index c3ff503..f5ddba0 100644 --- a/scripts/app.py +++ b/scripts/app.py @@ -377,10 +377,17 @@ def find_port(start=8722): return start -def main(): +def start_server(): + """起本地服务器(后台守护线程),立刻返回 (server, url)。 + 打包后的「原生窗口 / 浏览器」入口用它先把服务器跑起来,再开窗口。""" port = find_port() srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) - url = f"http://127.0.0.1:{port}" + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, f"http://127.0.0.1:{port}" + + +def main(): + srv, url = start_server() print("=" * 56) print(f" viralens 界面已启动 → {url}") print(" 浏览器没自动开就手动复制上面这个地址。") @@ -391,7 +398,7 @@ def main(): except Exception: pass try: - srv.serve_forever() + threading.Event().wait() # 一直挂着,等同 serve_forever(服务器在守护线程里) except KeyboardInterrupt: print("\n已停止。") From 3c4cf432b17836c71910bf415ada8ecc401cba88 Mon Sep 17 00:00:00 2001 From: Harry Alexander Xin Date: Mon, 1 Jun 2026 23:18:12 +0800 Subject: [PATCH 4/4] Add Download badge to README (links to Releases) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 20b2422..b812eed 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ viralens [![License: MIT](https://img.shields.io/badge/License-MIT-FD4E63.svg)](LICENSE) + [![Download](https://img.shields.io/badge/⬇%20Download-Win%20·%20Mac%20·%20Linux-2EA44F)](https://github.com/HarryXin0919/viralens/releases/latest)  ![Python](https://img.shields.io/badge/Python-3.10+-FD4E63)  ![Platforms](https://img.shields.io/badge/Bilibili%20+%20YouTube-FD4E63)  ![Approach](https://img.shields.io/badge/hypothesis--driven-✓-FD4E63)