diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..10629c7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,102 @@
+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
+
+ # 原生窗口后端只在 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
+
+ - 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/拖进来` 后再双击。
+
+ 启动后会直接弹出 **viralens 应用窗口**(没有终端、没有浏览器标签页;没装系统 WebView 的部分 Linux 环境会自动回退到用浏览器打开)。首次在界面里填入你的 **B站 SESSDATA** 和/或 **YouTube API key** 即可开始抓取分析。
+
+ > 可选功能「下视频分析开场镜头 + 配乐」需要自行安装 **ffmpeg**(其余抓取 / 分析 / 报告功能都无需它)。
diff --git a/README.md b/README.md
index 1c64001..b812eed 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
差距不来自选题或运气,而来自视频是否在 TA 的招牌形式里。偏离核心形式(商单 / vlog / 访谈 / 直播回放)就容易翻车。
[](LICENSE)
+ [](https://github.com/HarryXin0919/viralens/releases/latest)



@@ -133,6 +134,28 @@ 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 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 |
+|---|---|---|
+| **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 +271,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 +312,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..e926539
--- /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 ".[gui]" # [gui] = pywebview,双击弹原生窗口
+& $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..9858320
--- /dev/null
+++ b/packaging/viralens.spec
@@ -0,0 +1,118 @@
+# -*- 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"]
+
+# 原生窗口后端(可选):装了 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 跑它们,
+# 静态分析有可能看不全,这里全部显式声明,确保都被冻进去。
+hiddenimports += [
+ "runtime", "app", "viralens",
+ "fetch_multi", "fetch_bilibili", "fetch_youtube",
+ "compare_form", "creator_profile", "scan_signals", "charts", "export_data", "build_report",
+ "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=False, # 窗口模式:不弹终端。子进程 stdout 由 viralens_app._ensure_std() 接回管道
+ 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..69ef9b0
--- /dev/null
+++ b/packaging/viralens_app.py
@@ -0,0 +1,66 @@
+"""
+viralens · 打包成桌面 app 时的入口(被 PyInstaller 冻结,见 viralens.spec)。
+
+目标:双击直接弹出一个**原生应用窗口**(pywebview)——没有终端、没有浏览器标签页。
+缺原生 webview 运行时(如部分 Linux 没装 WebKitGTK)时,自动回退到「开浏览器」。
+
+两种角色,靠命令行第一个参数区分:
+ · 正常双击启动 → 起本地服务器 + 开原生窗口(或回退浏览器)。
+ · 自己重新拉起 → 形如 `viralens --vl-exec fetch_multi --force`:流水线某一步,
+ 由 runtime.dispatch_if_worker() 接管、当成 __main__ 跑掉再退出(不开窗口)。
+
+注意:本 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 子步骤:跑完即退出,绝不往下走(不开窗口)
+
+import app
+
+# —— 主进程:起服务器,开原生窗口;开不出来就回退浏览器 ——
+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/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..f5ddba0 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)}
@@ -373,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(" 浏览器没自动开就手动复制上面这个地址。")
@@ -387,7 +398,7 @@ def main():
except Exception:
pass
try:
- srv.serve_forever()
+ threading.Event().wait() # 一直挂着,等同 serve_forever(服务器在守护线程里)
except KeyboardInterrupt:
print("\n已停止。")
diff --git a/scripts/build_report.py b/scripts/build_report.py
new file mode 100644
index 0000000..eddea00
--- /dev/null
+++ b/scripts/build_report.py
@@ -0,0 +1,358 @@
+"""
+viralens · build_report.py —— 把 --report 跑出来的分析数据汇总成**一个自包含的 HTML 报告**。
+
+读 data/ 里各步骤产物(cross_creator_form / creator_profile / signal_scan / all_videos)
++ 把 reports/img/*.png 以 base64 内联进来 → 写出单个 reports/index.html。
+这个文件不依赖本地服务器、不依赖网络:双击就能开,也能直接发给任何创作者看。
+
+被 viralens.py 的 --report 模式调用,也能单独跑:
+ python build_report.py
+纯标准库(仅可选 import features 取人类可读的维度名)。
+"""
+import base64
+import html
+import json
+import sys
+import time
+from runtime import DATA, REPORTS, IMG
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+ACCENT = "#61C4E3" # 项目主色(与 charts.py 一致)
+HIT = "#E5484D" # 爆款红
+
+
+# ————————————————————————— 小工具 —————————————————————————
+def load(name):
+ p = DATA / name
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+
+
+def esc(s):
+ return html.escape(str(s if s is not None else ""))
+
+
+def fmt_play(n):
+ """中文友好的播放量:1.2亿 / 814万 / 9532。"""
+ try:
+ n = float(n)
+ except (TypeError, ValueError):
+ return "-"
+ if n >= 1e8:
+ return f"{n/1e8:.2f}亿"
+ if n >= 1e4:
+ return f"{n/1e4:.1f}万"
+ return f"{n:,.0f}"
+
+
+def fmt_dur(sec):
+ try:
+ sec = int(sec)
+ except (TypeError, ValueError):
+ return "-"
+ m, s = divmod(sec, 60)
+ return f"{m}:{s:02d}"
+
+
+def video_url(v):
+ plat = (v.get("platform") or "bilibili").lower()
+ vid = v.get("bvid") or v.get("vid") or ""
+ if not vid:
+ return ""
+ if plat == "youtube":
+ return f"https://www.youtube.com/watch?v={vid}"
+ return f"https://www.bilibili.com/video/{vid}"
+
+
+def img_data_uri(path):
+ try:
+ b = path.read_bytes()
+ except Exception:
+ return None
+ return "data:image/png;base64," + base64.b64encode(b).decode("ascii")
+
+
+# ————————————————————————— 各板块 —————————————————————————
+def section_headline(form):
+ """顶部三句话结论卡:形式决定天花板(头尾播放差)。"""
+ if not form:
+ return ""
+ ratios = [r["ratio"] for r in form if isinstance(r.get("ratio"), (int, float))]
+ if not ratios:
+ return ""
+ lo, hi = min(ratios), max(ratios)
+ chips = "".join(
+ f'{esc(r["creator"])} {r["ratio"]:.0f}×'
+ for r in sorted(form, key=lambda x: -(x.get("ratio") or 0))
+ )
+ return f"""
+
同一个创作者,头部 5 条 vs 尾部 5 条播放差 {lo:.0f}×–{hi:.0f}×
+ 图表
| 维度 | 同向 | 平均倍数 / ρ | 判决 |
|---|
共 {len(videos)} 条,显示前 {len(shown)} 条。点表头排序。
' if len(videos) > len(shown) else '点表头排序。
' + return f""" +| 创作者 | 平台 | 赛道 | 标题 | +播放 | 评论 | 弹幕/赞 | 时长 | 发布 | +
|---|
先在界面里「抓取并分析」,或命令行跑 python viralens.py --report,再生成报告。