No description
  • JavaScript 72.4%
  • Java 9.8%
  • Python 8.3%
  • HTML 8%
  • Shell 1.5%
Find a file
2026-10-12 17:06:19 +08:00
.pi hongguo-gui: Electron client for 红果免费短剧 with reverse-engineered API 2026-10-08 01:57:37 +08:00
electron test(signer): 桩补 stop()/密封自举(修复 2 既有红) 2026-10-12 17:06:19 +08:00
gui hongguo-gui: Electron client for 红果免费短剧 with reverse-engineered API 2026-10-08 01:57:37 +08:00
hongguo hongguo-gui: Electron client for 红果免费短剧 with reverse-engineered API 2026-10-08 01:57:37 +08:00
signsvc feat(app): 新设备签名自举 + 无 HEVC 平台内嵌播放(ffmpeg 转码代理) 2026-10-10 01:57:31 +08:00
.gitignore chore: gitignore .worktrees 2026-10-11 01:14:56 +08:00
hongguo-gui.desktop feat(player): 内嵌播放器(不再弹 mpv 窗口)+ fcitx5 中文输入修复 + 搜索按钮 CSP 修复 2026-10-08 05:46:55 +08:00
README.md perf(mpv): 兜底播放启用硬解 + README 记录 HEVC 原生内嵌结论 2026-10-11 01:04:22 +08:00
run.py feat(player): 内嵌播放器(不再弹 mpv 窗口)+ fcitx5 中文输入修复 + 搜索按钮 CSP 修复 2026-10-08 05:46:55 +08:00
start.sh feat(player): 内嵌播放器(不再弹 mpv 窗口)+ fcitx5 中文输入修复 + 搜索按钮 CSP 修复 2026-10-08 05:46:55 +08:00
waydroid_setup.sh hongguo-gui: Electron client for 红果免费短剧 with reverse-engineered API 2026-10-08 01:57:37 +08:00

红果免费短剧 · GNU/Linux 客户端

对 base.apk(红果免费短剧 com.phoenix.read 7.3.9.32)逆向实现的 Linux 桌面客户端。 Electron 界面 + unidbg 签名服务,纯 Node 数据层。

运行

cd electron
npm install            # 首次;electron 二进制若未下载见下方说明
npm start              # 或: ./node_modules/electron/dist/electron . --no-sandbox

依赖:Node ≥ 18、JDK 21(签名)、网络。签名器 jar(signsvc/lib/)与编译产物 (signsvc/hg/)均不入库——首次检出或缺失时 api.js 会在首次 boot 前自动执行 signsvc/setup.sh 从 Maven Central 下载并重编译(HG_SKIP_SETUP=1 可跳过,供测试打桩环境), 失败时报错并提示手动运行;原生 SO/证书在 ~/.cache/hongguo/hg/(HG_ASSETS 可覆盖;不再放 /tmp,曾因被清理导致签名全败)。

electron 二进制下载慢时:

curl -L -o e.zip https://npmmirror.com/mirrors/electron/v33.4.11/electron-v33.4.11-linux-x64.zip
python3 -c "import zipfile; zipfile.ZipFile('e.zip').extractall('node_modules/electron/dist')"
echo electron > node_modules/electron/path.txt && chmod +x node_modules/electron/dist/electron

功能

  • 🏠 首页:热门搜索词 + 为你推荐(分类推荐流分页拉满,服务端每页仅返 12 条; 失败自动回退热搜词搜索;2026-10 起 landing 改返推荐流形态 video_info,解析已双形态兼容)
  • 🎭 剧场:短剧分类浏览(new_category/landing/v,genre_type 130),可翻页
  • 🧭 发现(免签,不占用签名后端):今日上新(短剧标签簇语义 + 漫剧/AI 官方 7 天筛选)、榜单(漫剧热播/推荐/新剧 TOP50,带名次角标)、筛选浏览 (体裁 × 排序 × 时间 × 受众多维组合,可翻页)
  • 👤 我的:收藏 / 观看历史(localStorage 本地持久化);观看历史行封面由卡片点击时 记录的封面缓存 + 后台详情回填补齐(旧条目无封面也可恢复)
  • 🔍 短剧搜索:search/tab/v(tab_name=store,App 同源新目录)+ 「加载更多」分页 (翻页三件套游标 passback/search_id/next_offset,与 App 同源,实测翻页零重复)
  • 📖 详情页:封面/简介/分类/剧集目录(video_tab/multi_video_detail/v 为准), 长剧支持输入集数定位
  • ▶ 内嵌播放器(不弹出外部窗口):HEVC/bytevc1 源在本机(NVIDIA 桌面)无法原生 内嵌解码 —— Electron 全系编译期裁除 HEVC 解析(libffmpeg 无 ff_hevc_parser,v33~v44 实测一致),且上游 Chromium 默认跳过 NVIDIA VA-API 设备(VaapiOnNvidiaGPUs 可解除 跳过,但 NVIDIA 私有驱动 × xwayland-satellite 的 EGL 栈不健康,解码器初始化仍不通; Intel/AMD 核显设备 VA-API 原生可用,渲染进程 canPlayType 探测自动走原生)。为此 bytevc1-only 内容走 ffmpeg 转码代理(NVENC 硬编,详见 electron/transcode.js), 内嵌全功能保留;mpv 兜底 --hwdec=auto-safe(经 libva-nvidia-driver 桥接 NVDEC 硬解)。 未加密流直连 CDN;CENC 加密流走 TT 播放器 密钥派生 + 本地解密代理(见下文「TT DRM 密钥派生」),支持整流加密 (cbc/cbc512/ctr 自动探测)与逐样本 CENC(渐进式 MP4,stbl 内 senc 携带逐样本 8B IV、整样本 AES-CTR;代理按样本解密并同尺寸改写盒类型,输出标准明文 MP4), 解密后的明文流经 127.0.0.1 HTTP 服务喂给窗口内 <video>;解码失败自动降级:备用线路 → Web 源(hongguoduanju.com,每剧前 3 集)→ 外部 mpv 兑底。播放器功能: 自动连播(可关)、音量/静音记忆、0.5–3x 倍速、进度拖动与缓冲显示、 断点续播(按剧记忆,删历史时同步清除)、剧集抽屉、跳集、查找其他季、 停滞约 20s 显示手动「重试当前集」、App 密度预取(接下来 4 集一次签名批量拉取, 连播/切集秒开)、全屏、老板键 Ctrl+Shift+B(隐藏窗口自动暂停,再次启动应用唤回续播, 手动暂停保持)、键盘快捷键(空格 / ←→ / ↑↓ / M / N / P / F / Esc)。 选源策略:可解码编码优先(排除 bytevc2)→ 分辨率优先 → 同分辨率比大小
  • ⌨️ 中文输入(fcitx5):Wayland 会话自动切原生 ozone 后端并启用 --enable-wayland-ime --wayland-text-input-version=3(text-input-v3 → niri/sway/KDE → fcitx5);X11 会话自动设置 GTK_IM_MODULE=fcitx5、 XMODIFIERS=@im=fcitx 兑底

签名器为常驻进程 + 开机探针(2 槽位并发、nice 10):实测一个 JVM 能否签名在 初始化时就已注定(collector 竞态,约 40–50% 概率)——「好 JVM」每个请求第 0 轮即成功 (跨 URL、带 gzip body 均可),「坏 JVM」多轮重试也救不活。因此进程起来后先用 3 轮 探针试签:好 JVM 留用终生(此后签名 0.1s 级,不再逐次冷启动),坏 JVM 立即更换重抽。 请求走 stdin JSON 行协议({"url", "body_b64", "rounds"}),每个请求结束后清空 msdata(状态文件绑定 (url, body),但绝不能在首个请求前清——会毁掉 initFull 刚建好的 collector 状态,HG_WIPE=none|after|before 可对照调试)。单 URL 最多 12 轮重试, 连续 2 次失败或崩溃时 Java 侧 exit(3) 由 Node 侧自动重建;结果按 (URL+请求体) 永久磁盘缓存(~/.cache/hongguo/signs.json)——同一资源只需签一次;搜索的 app_launch_time 按会话冻结。播放地址另有 10 分钟响应缓存 + App 密度预取: preload 端点原生支持 mixed_video_id_map 多 vid,接下来 4 集一次签名批量拉取, 连播与点播秒开(预取链路永不抛错)。 实测:签名器启动(含抽签换 JVM)约 2.7–4.5s 一次,之后单签名 30–65ms(dynarmic 后端); 启动预热:require('./api.js') 即后台拉起签名槽并预签首屏两条 GET(首页 landing + 剧场分类树,与真实请求同源逐字节一致),UI 首屏签名命中磁盘缓存后 只剩纯网络耗时(实测 landing ~1s),冷启动移出感知路径(单测 warmup.test.js); 修复:kill("KILL") 在 Node ≥26 与 Electron 33 内置 Node 20.18 均抛 ERR_UNKNOWN_SIGNAL 且被空 catch 吞掉——_kill 一直是无声空操作,坏 JVM 更换/启动放弃全在漏 JVM (靠父进程退出 stdin EOF 才收敛),改用 SIGKILL 并区分外部终止 stop()/内部清理 _kill(); 批量预取 4 集仅 1 次签名 + 1 次 HTTP(~1s);常驻 JVM 空闲 CPU 0%、RSS ~140MB。

失败不再致命:签名器崩溃/失败自动重建并重试(请求级 150s 硬死线,到点强制 击毙重建);device_register 失败退化为本地随机设备(下次启动重试注册);前台取数 批量失败自动退化为逐集请求、再失败才走 UI 降级链(备用线路 → Web 源 → mpv); 预取链路失败静默放弃(不重试、不拖慢前台),全程不抛错。

详情页以 video_tab/multi_video_detail/v/(剧集目录,含标题/简介/封面/时长)为准, detail/v/ 仅为可选增强(2026-10 起该端点对本设备偶发返回 200 空体,疑似风控; device_register 亦被拒——同一 IP 大量调用所致)。

TT DRM 密钥派生(2026-10 重写,electron/ttdrm.js)

对 libttmplayer.so(7.3.9.32 / ttplayer 2.10.252.980-novel)+ libttffmpeg.so 的 完整逆向还原,替代原先「遇加密即放弃」的占位逻辑:

[Java]   TTVideoEngineImpl._doParseIPAddress()
           → mMediaPlayer.setStringOption(144, videoInfo.getValueStr(5))   // spadea
[native] ff_demuxer_android.cpp setOptions()  (libttmplayer+0x1f457c)
           key = derive(属性0x7531=spadea, 属性0x7541=authKey(Java opt 462, 本App未设),
                        配置34004=authIdx(Java opt 471), 配置151=flag(默认0))
           av_dict_set(opts, "decryption_key", key, 0)
[native] derive = 0x175804 → 0x175a80(b64解码+取flag) → 0xc7120(解析+变换):
           v = b0^b1^b2; headLen = v-'0'; tailLen = len-v+47
           k = spade[len-headLen-2] ^ spade[len-headLen-1]
           head = 末尾 headLen 字节 ^ k       → "app_v2"/"web_v2" ⇒ V2
           tail = spade[1 .. 1+tailLen)
           V2: 先交换相邻字节对,再 out[i] = (b[i]^b[i-2]) - b[i-1] + δ(i)
           V1:            out[i] = (b[i]^b[i-2]) + δ(i)      (b[-2]=0xfa, b[-1]=0x55)
           δ(i) = flag ? +(21+popcount(i)) : -(21+popcount(i))
           tail[0] = out2 长度(十六进制数字);out1 = tail[1..keyLen),out2 = 版本标记
           out2 == "2" 且 authKey 非空 ⇒ AES-256-GCM:
              key = ASCII(md5hex(authKey+str(authIdx)))   ← 32 字符作 32 字节密钥(非16B摘要!)
              iv = 4b68454b9cabee47ca04f404, tag = ct 末 16B, 明文即十六进制 decryption_key
           否则 out1 本身即十六进制 decryption_key(本 App 实际路径)
[native] 流为**逐样本 CENC**(已用真实流验证):非分片 MP4,stbl 内非标准位置的
           senc 盒携带逐样本 8B IV(整样本加密,无子样本),CTR 计数块 =
           IV 左对齐补零到 16B;mpv/ffmpeg 的 mov demuxer 用 decryption_key
           原生解密(api:mpv 已集成 --demuxer-lavf-o=decryption_key=<hex>)。
           check_info "c:<s>-<e>-<crc>" = mdat 之前明文区的 CRC16-CCITT(init 0);
           "e:<s>-<e>-<crc>,…" = 文件绝对窗口的探测 CRC(前 1024B 拆两半,防劫持)

electron/ttdrm.js 实现:parseSpadea / deriveKey / parseCheckInfo / crc16Ccitt / parseCencMp4(TtDrmProxy 为无 mpv-CENC 环境的本地解密代理, 支持整流 cbc/cbc512/ctr 与逐样本 CENC 模式)。 单测:node electron/test/ttdrm.test.js(正向构造 + 逆向派生往返、GCM、代理端到端)、 node electron/test/cenc.test.js(合成渐进式 CENC 流的探测/解密/盒改写/Range)、 node electron/test/warmup.test.js(启动预热:HOME 隔离 + URL 同源断言;会真实启动 JVM,~10s)、 node electron/test/discover.test.js(免签发现面 + 假签名器 JVM + fetch 打桩,离线快跑)。

逆向结论

项目 值
包名 / 版本 com.phoenix.read 7.3.9.32 (73932),番茄同源代码库
aid 8662(AppProperty.getAppIdString())
API 网关 api5-normal-{lf,hl}.fqnovel.com
请求签名 必需。mssdk libmetasec_ml.so v4.10.3,JNI 入口 ms.bd.c.m.a(IIJ...) @ so+0x297890
签名命令 0x2000006 → X-Argus/Gorgon/Helios/Khronos/Ladon/Medusa(绑定完整 URL 的 query)
设备注册 applog device_register(log.snssdk.com/service/2/)。必须用信封格式 {magic_tag:"ss_app_log", header:{…}, _gen_time}(与 hongguo/device.py 一致);扁平 payload 会被拒(device_id=0)。header 用 display_name/app_name: "phoenix_read"、device_model(非 device_type),建议带 X-SS-REQ-TICKET;连发会限频,需重试(api.js registerDevice 已实现)
签名稳定性 JVM 可签名性在初始化时即注定(collector 竞态,~40–50% 好 JVM;好 JVM 跨 URL/带 body 均可第 0 轮签成)→ 常驻进程 + 开机 3 轮探针,坏 JVM 即弃,好 JVM 终生留用 + 失败自动重启
模拟后端 默认 dynarmic(2026-10 实测:好 JVM 单签中位 40ms vs unicorn2 235ms,约 5x;启动多 ~0.7s 被启动画面掩盖;好坏 JVM 抽签比例与后端无关,5/6 vs 5/6)。HG_BACKEND=unicorn2 可回退
关键修复 mssdk 状态文件路径是斜杠包名 /data/user/0/com/phoenix/read/files/.msdata(native 自行把包名转路径,MS.b(65539) 的返回值被忽略),路径映射错误会导致 collector 永远无法完成
短剧判别 搜索结果 book_data.platform: "1"=短剧, "2"=小说(genre 都是 0,不能靠 genre 区分)

已打通的 API

GET  /reading/bookapi/search/cue/v/              热搜词(免签名)
GET  /reading/bookapi/search/page/v/             搜索
GET  /reading/bookapi/detail/v/?book_id=         详情
GET  /reading/bookapi/directory/all_items/v/     目录(item_data_list)
GET  /reading/bookapi/video_tab/multi_video_detail/v/?video_series_id=&vs_id_type=1
                                                  剧集元数据(vid/时长/集数)★
POST /novel/player/video_detail/v1/              播放页详情(596KB,含 vid)★
GET  /reading/bookapi/new_category/video/info/v/ 短剧分类树
GET  /reading/bookapi/bookstore/homepage/v/      书城首页
POST /reading/bookapi/audio/playurl/             音频播放(对短剧返回 NO_THIS_TONE)

★ 标记为本次会话新打通。multi_video_detail 确认 vid == item_id。

免签发现面(2026-10-08 实测:不校验 X-Argus,游客态并发均 code=0)

逆向「渠道有数」红果 Windows 桌面版(Tauri+Python+unidbg,其明文后端随安装包分发) 获得线索后,用本项目设备/网关复核成立;高频发现面全部普通 fetch,不触碰签名后端:

GET  /reading/bookapi/bookmall/cell/change/v/    榜单(tab_type=26 漫剧聚合位,
                                                  sub_selected_items 区分热播/推荐/新剧)
POST /reading/distribution/category/landpage/v/  多维筛选/今日上新/最新上架
                                                  (仅在 api5-normal-sinfonlinea 网关存活;
                                                  need_selector_panel=true 返回实时筛选面板;
                                                  select_items: genre/sort/online_time/gender/…;
                                                  filter_ids=已见 series_id 供翻页去重)

同源结论(对方实现佐证):搜索/multi_video_model 对游客开放且不校验 device_id 是否注册 ——随机设备 + 签名即可 code=0(本项目仍走注册,失败已自动退化为本地随机设备); 列表类响应中 main_url/backup_url 可能是 base64 形态(_toHttpUrl 已容错); CDN 直链 url_expire≈6h(播放缓存 TTL 已放宽至 4h)。

封面 heic 问题:免签来源(landpage/bookmall)封面为 image/heic(Linux Chromium 不解码),landing/书卡来源为 jpeg。主进程本地封面代理(固定端口 21840,被占时退回 动态端口):heic 用 heic-decode(wasm)+jpeg-js 转 jpeg,全部封面落盘 ~/.cache/hongguo/thumbs/(哈希寻址 + LRU 清理),img-src * 天然放行; 依赖缺失时透传原图优雅降级。剧场分类推荐流未覆盖的分类(如「女婿」), 按分类名精确匹配免签筛选面板降级 landBrowse,仍无则显示准确空态。

RPC 框架编码规则(逆向得出)

  • Java RPC(com.bytedance.rpc/ff1):GET 请求对象经 kf1.g.f() 展平为 query —— 枚举转数字 (WireEnum.getValue)、List 转逗号分隔、嵌套 POJO 转 Java toString(服务端忽略); POST 走 Gson JSON body。
  • KMP RPC(multi.rpc):POST body 为 kotlinx JSON,枚举经 EnumAsOrdinal 序列化器转数字。

播放链路(未完成,下一步方向)

剧集 → vid(已确认 = item_id)→ 播放地址。卡点:

  1. POST /novel/player/video_model/v1/(正确端点,请求形状已还原)→ 101000 服务异常。 疑似需要 NovelCommonParam 的 Sign-Env/Sign-Res 安全校验字段 (cq6/d3.java),其取值来自 mssdk 运行时。
  2. POST /reading/media/video/info/(thrift, psm toutiao.reading.mediaapi)→ 1303 thrift parse status failed,TBinaryProtocol 编码 {1:list<string> vids, 2:bool is_encrypt} 未被接受,可能需要 TTNet 私有 thrift 封套。
  3. POST /reading/distribution/video_model_lite/v1/ → 101000 get audio/video model failed。
  4. 备选:registerkey + reader/full 正文解密链(content 构造已完全逆向: AES-CBC,key=hexac25c67ddd8f38c1b37a2348828e222e,IV=随机16字符, 明文=device_id/iid 各 8 字节大端,见 tg7/i.java)。

目录结构

electron/          Electron 应用(main/preload/renderer + api.js 数据层 + ttdrm.js DRM)
electron/test/     ttdrm 单元测试(node electron/test/ttdrm.test.js)
signsvc/           Java 签名器(HgSign.java + unidbg;常驻 --serve 模式,由 api.js 驱动)
hongguo/           Python 参考实现(device/signer/api,与 electron/api.js 等价)
base.apk           原始 APK

签名器运行依赖(/tmp 重启后需重建;全部可从 Maven Central 拉取):

mkdir -p /tmp/hg/lib/arm64-v8a /tmp/refso/data /tmp/unidbg_jars
unzip -o -j base.apk "lib/arm64-v8a/libmetasec_ml.so" "lib/arm64-v8a/libc++_shared.so" \
  -d /tmp/hg/lib/arm64-v8a/
cp base.apk /tmp/hg/base.apk
M=https://repo1.maven.org/maven2
for a in \
  com/github/zhkl0228/unidbg-android/0.9.8/unidbg-android-0.9.8.jar \
  com/github/zhkl0228/unidbg-api/0.9.8/unidbg-api-0.9.8.jar \
  com/github/zhkl0228/unidbg-unicorn2/0.9.8/unidbg-unicorn2-0.9.8.jar \
  com/github/zhkl0228/unicorn/1.0.14/unicorn-1.0.14.jar \
  com/github/zhkl0228/keystone/0.9.7/keystone-0.9.7.jar \
  net/java/dev/jna/jna/5.10.0/jna-5.10.0.jar \
  org/scijava/native-lib-loader/2.3.5/native-lib-loader-2.3.5.jar \
  net/dongliu/apk-parser/2.6.10/apk-parser-2.6.10.jar \
  org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar \
  org/slf4j/slf4j-simple/1.7.36/slf4j-simple-1.7.36.jar \
  com/alibaba/fastjson/1.2.83/fastjson-1.2.83.jar \
  commons-codec/commons-codec/1.15/commons-codec-1.15.jar \
  org/apache/commons/commons-collections4/4.4/commons-collections4-4.4.jar \
  commons-io/commons-io/2.11.0/commons-io-2.11.0.jar \
  commons-logging/commons-logging/1.2/commons-logging-1.2.jar ; do
  curl -sfL -o /tmp/unidbg_jars/$(basename $a) $M/$a || echo "FAIL $a"
done
cd signsvc && javac -cp "$(cat cp.txt)" -d . HgSign.java

注意:cp.txt 允许尾部换行(setup.sh 的 paste 天然产生,api.js 读取时会 trim); 签名器 .class 不入库(编译产物易陈旧干扰 review),检出后跑 setup.sh 重建; /tmp/refso/data/ms_16777218.bin(mssdk 证书,真机拉取)非必需——无证书也能完成 init 与签名。

播放链路(2026-10-08 更新:已完全打通,端到端验证)

通过 waydroid 真机 + mitmproxy TLS 解密 + 请求重放差分,已完全打通 pack 服务; 密钥派生已重写实现并用真实流验证(见「TT DRM 密钥派生」):

已攻破:

  • 播放端点:POST https://reading.snssdk.com/novel/player/multi_video_model/preload/v1?<公共参数>
  • 请求体:gzip JSON {biz_param{video_platform:1024, 不带 device_level, ...}, dr_scene:"preload", mixed_video_id_map:{"1":["vid"]}}
  • 签名必须覆盖 body:mssdk 第 5 参 = gzip body 字节;头 x-ss-stub: MD5(gzip) 大写 + Content-Encoding: gzip
  • 设备身份:applog device_register 信封格式即可(无需真机;见「逆向结论」表)
  • 搜索换 search/tab/v/(tab_name=store)→ 新目录可播剧
  • 响应 data.<vid>.video_model.video_list[] 含 video_meta.codec_type:bytevc2=私有编码(不可解)、bytevc1=HEVC/hvc1(可解)
  • 密钥派生:encrypt_info.spade_a → ttdrm.deriveKey() → 32 字符十六进制密钥
  • 流结构(真实流验证):非分片 MP4,moov 后紧跟 mdat;视频轨 encv(sinf/tenc ivSize=8)+ stbl 内非标准位置的 senc 盒(逐样本 8B IV,整样本加密,无子样本);CTR 计数块 = IV 左对齐补零到 16B。 mpv/ffmpeg 原生支持:mpv --demuxer-lavf-o=decryption_key=<hex> <url>(api:mpv 已集成)
  • 端到端验证:派生密钥解密真实流前 200 个视频样本,HEVC NAL 长度链全部自洽(首样本为 VPS)
  • check_info 语义(已验证):c:<s>-<e>-<crc> = mdat 之前明文区的 CRC16-CCITT(init 0); e:<s>-<e>-<crc>,… = 文件绝对窗口的探测 CRC(前 1024B 拆两半,防 CDN 劫持;旧「512B 加密块」理解有误)

遗留:无。App 目录加密流可直接播;Web 源(hongguoduanju.com,每剧 3 集)保留为兜底。