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 @@ 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) @@ -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}×

+

差距不来自选题或运气,而来自视频是否在 TA 的招牌形式里。偏离核心形式(商单 / vlog / 访谈 / 直播回放)就容易翻车。

+
{chips}
+
""" + + +def section_charts(): + """内联 reports/img 下生成的图表 PNG。""" + candidates = [ + ("form_spread.png", "形式决定天花板 —— 各创作者头尾播放差"), + ("second_person_falsified.png", "被我们自己证伪的「通用杠杆」"), + ("meme_falsified.png", "玩梗 / 社群参与度 跨创作者检验"), + ] + blocks = [] + for fn, cap in candidates: + uri = img_data_uri(IMG / fn) + if uri: + blocks.append( + f'
{esc(cap)}
{esc(cap)}
' + ) + if not blocks: + return "" + return f'

图表

{"".join(blocks)}
' + + +def _pct(x): + try: + return f"{float(x)*100:.0f}%" + except (TypeError, ValueError): + return "-" + + +def section_creators(profile, form): + """每位创作者一张卡:同区基准对位 + 趋势 + 头/尾代表作。""" + form_by = {r["creator"]: r for r in (form or [])} + cards = [] + zones = (profile or {}).get("zones", {}) if isinstance(profile, dict) else {} + seen = set() + for zone, zd in zones.items(): + bench = zd.get("benchmark", {}) or {} + for pr in zd.get("creators", []): + name = pr.get("name", "?") + seen.add(name) + s = pr.get("stat", {}) or {} + t = pr.get("trend", {}) or {} + fr = form_by.get(name) + bench_line = ( + f'
体量(播放中位){fmt_play(s.get("play_med"))}' + f'区典型 {fmt_play(bench.get("play_med"))}
' + f'
招牌形式命中率{_pct(s.get("in_format_rate"))}' + f'区典型 {_pct(bench.get("in_format_rate"))}
' + f'
典型时长{fmt_dur(s.get("dur_med"))}' + f'区典型 {fmt_dur(bench.get("dur_med"))}
' + f'
每万播放评论{esc(s.get("comment_per_10k"))}' + f'区典型 {esc(bench.get("comment_per_10k"))}
' + ) + if t.get("verdict") and t.get("verdict") != "样本不足": + trend_line = (f'
趋势:{esc(t.get("verdict"))} · ' + f'近/早 {esc(t.get("ratio"))}×({fmt_play(t.get("early_med"))} → {fmt_play(t.get("recent_med"))},' + f' {esc(t.get("n_mature"))} 条成熟视频)
') + else: + trend_line = f'
趋势:{esc(t.get("note") or "样本不足")}
' + reps = "" + if fr: + def li(v, kind): + tag = f'[{esc(v["off"])}] ' if v.get("off") else "" + return f'
  • {fmt_play(v.get("play"))} {tag}{esc(v.get("title"))[:40]}
  • ' + tops = "".join(li(v, "hit") for v in (fr.get("top5") or [])[:3]) + bots = "".join(li(v, "flop") for v in (fr.get("bot5") or [])[:3]) + reps = (f'
    🔴 头部代表作
    ' + f'
    🔵 尾部代表作
    ') + ratio_badge = f'头尾 {fr["ratio"]:.0f}×' if fr and fr.get("ratio") else "" + cards.append(f""" +
    +

    {esc(name)}

    {esc(zone)}{ratio_badge}
    +
    {bench_line}
    + {trend_line} + {reps} +
    """) + # creator_profile 没覆盖、但 form 里有的,也补一张精简卡 + for name, fr in form_by.items(): + if name in seen: + continue + ratio_badge = f'头尾 {fr["ratio"]:.0f}×' if fr.get("ratio") else "" + cards.append(f'

    {esc(name)}

    {ratio_badge}
    ') + if not cards: + return "" + return f'

    各创作者画像

    {"".join(cards)}
    ' + + +def section_signals(scan, binlab, numlab): + if not isinstance(scan, dict): + return "" + metric = scan.get("metric", "") + rows = [] + for g in scan.get("generalization", []): + label = binlab.get(g["key"], g["key"]) + same = f'{g.get("pos",0)}↑ / {g.get("neg",0)}↓ / 共{g.get("n",0)}' + rows.append(f'{esc(label)}{esc(same)}' + f'{esc(g.get("geo_ratio"))}×{esc(g.get("verdict"))}') + for g in scan.get("generalization_numeric", []): + label = numlab.get(g["key"], g["key"]) + same = f'{g.get("pos",0)}+ / {g.get("neg",0)}- / 共{g.get("n",0)}' + rows.append(f'{esc(label)}{esc(same)}' + f'ρ {g.get("mean_rho"):+.2f}{esc(g.get("verdict"))}') + if not rows: + return "" + return f""" +
    +

    哪些杠杆通用,哪些因人而异 (指标:{esc(metric)})

    + + {"".join(rows)}
    维度同向平均倍数 / ρ判决
    +
    """ + + +def section_table(videos): + if not videos: + return "" + rows = [] + shown = videos[:500] + for v in shown: + u = video_url(v) + title = esc(v.get("title"))[:60] + title_html = f'{title}' if u else title + rows.append( + f'' + f'{esc(v.get("creator"))}' + f'{esc(v.get("platform"))}' + f'{esc(v.get("zone"))}' + f'{title_html}' + f'{fmt_play(v.get("play"))}' + f'{esc(v.get("comment"))}' + f'{esc(v.get("danmaku") if v.get("danmaku") is not None else v.get("like"))}' + f'{fmt_dur(v.get("duration_sec"))}' + f'{esc((v.get("created_iso") or "")[:10])}' + f'' + ) + note = f'

    共 {len(videos)} 条,显示前 {len(shown)} 条。点表头排序。

    ' if len(videos) > len(shown) else '

    点表头排序。

    ' + return f""" +
    +

    视频明细

    + {note} + + + + {"".join(rows)}
    创作者平台赛道标题播放评论弹幕/赞时长发布
    +
    """ + + +HEAD = """ + + +viralens 报告 +
    """ + +FOOT_TMPL = """ +
    """ + + +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/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/