sharoon7171/ployan-hls-stream-resolver

GitHub: sharoon7171/ployan-hls-stream-resolver

一个零依赖的 Node.js HLS 流解析器,通过抓取嵌入页、解密会话令牌并代理 HLS 请求,将 Ployan 播放器的加密握手流程还原为可直接播放的 M3U8 地址。

Stars: 0 | Forks: 0

# Ployan HLS 流解析器 Ployan 嵌入 → HLS 握手的 Node.js 实现。零 npm 运行时依赖。 示例嵌入页面:[flixhqz.com/movie/scream-7-1630860919/](https://flixhqz.com/movie/scream-7-1630860919/) ## 目录 - [简介](#introduction) - [解密的内容](#what-gets-decrypted) - [解密原理](#how-decryption-works) - [流解析原理](#how-the-stream-is-resolved) - [为什么无法直接在浏览器中播放](#why-it-cannot-play-directly-in-the-browser) - [代理原理](#how-the-proxy-works) - [技术栈](#stack) - [代码结构](#code-map) - [REST API](#rest-api) - [免责声明](#disclaimer) ## 简介 像 `https://flixhqz.com/movie/scream-7-1630860919/` 这样的观看页面并不是流本身。它只是一个外壳——包含标题、布局、`mediaId` 以及挂载 Ployan 播放器的 JavaScript 代码。**HLS 播放列表 URL 永远不会出现在 HTML 中**。播放器在通过加密的 session token 验证后,会从单独的 API 主机获取该 URL。 整个过程涉及三个来源: | 层级 | 作用 | | --- | --- | | 嵌入站点 (`flixhqz.com`) | 提供 HTML,暴露 `mediaId` | | Player API (来自 `plyURL`) | 验证 token,返回 `info` | | CDN (位于 M3U8 响应内) | 提供 `.ts` HLS 切片 | ``` flowchart LR A[Embed URL] --> B[Scrape HTML] B --> C[Decode plyURL] B --> D[Read mediaId] C --> E[Seal token] D --> E E --> F["GET /get/{token}"] F --> G[master.m3u8 URL] G --> H[VLC / MPV / Proxy] ``` 本仓库在服务端复现了该链路,并通过 `GET /api/stream`、`GET /api/proxy` 以及一个浏览器 UI 将其暴露出来。 ## 解密的内容 Ployan 隐藏了两个值。在相关技术文章中,它们都被统称为“解密”——但实际是不同的操作。 | | **`plyURL`** | **Session token** | | --- | --- | --- | | 转换方式 | Base64 解码 | AES-256-GCM 封装 | | 隐藏的值 | Player API 主机名 | `{mediaId}+{episode}+1+{timestamp}` | | 方向 | 混淆字符串 → `https://…` 源站 | 明文 → `/get/{token}` 中的十六进制数据块 | | 破解者 | 任何拥有该 HTML 的人 | Ployan 服务器进行解密;本仓库进行重新封装 | 在本项目中,以下内容未在任何地方进行解密:`/get` 的 JSON 响应、M3U8 播放列表文本或视频切片。 ## 解密原理 ### 解码 `plyURL` Player API 主机以 base64 格式存储在页面的 JavaScript 中: ``` const plyURL = "aHR0cHM6Ly9wbGF5ZXIuZXhhbXBsZS8="; ``` 这仅仅是混淆——没有密钥,也没有 salt。 ``` const PLY_RX = /const\s+plyURL\s*=\s*["']([A-Za-z0-9+/=]+)["']/; ``` 匹配 → 补全 → `Buffer.from(…, "base64")` → 去除末尾斜杠 → **`origin`**。 ### 封装 Session token Ployan 客户端在发起 `GET {origin}/get/{token}` 请求前会对 payload 进行加密。本仓库在 `src/ployan/hls.js` 中执行了相同的封装操作。 **明文 (Plaintext)** ``` {mediaId}+{episode}+1+{unixTimestamp} ``` | 字段 | 值 | | --- | --- | | `episode` | 默认为 `1` | | 中间的 `1` | 硬编码的服务器插槽 | | `unixTimestamp` | `Math.floor(Date.now() / 1000)` —— 过期的 token 会被拒绝 | **PBKDF2-SHA256** | 参数 | 值 | | --- | --- | | 密码 | `"player"` | | Salt | 8 个随机字节 | | 迭代次数 | `1000` | | 密钥长度 | 32 字节 | **AES-256-GCM** | 参数 | 值 | | --- | --- | | IV | 12 个随机字节 | | 认证标签 (Auth tag) | 16 字节,附加在密文之后 | **传输格式:** `{saltHex}-{ivHex}-{ciphertextHex}{tagHex}` ``` function seal(plain) { const salt = randomBytes(8); const key = pbkdf2Sync("player", salt, 1000, 32, "sha256"); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); return `${salt.toString("hex")}-${iv.toString("hex")}-${body.toString("hex")}${cipher.getAuthTag().toString("hex")}`; } function token(mediaId, episode) { return seal(`${mediaId}+${episode}+1+${Math.floor(Date.now() / 1000)}`); } ``` Ployan 服务器以 `-` 为分隔符进行拆分,利用 salt + `"player"` 重新推导密钥,进行解密,验证 GCM 认证标签,并校验各个字段。请**每次解析时都生成新的 token**。 ## 流解析原理 `src/route/stream.js` 负责整体调度: ``` scrape(url) → hls({ origin, mediaId, episode }) → { title, url, mode } ``` **抓取** (`src/embed/scrape.js`) —— 对嵌入 URL 发起一次 `GET` 请求,带有 `Referer: {page-origin}/` 头以及 `src/env.js` 中配置的 headers: | 字段 | 来源 | | --- | --- | | `mediaId` | `#mid` / `#watch-block` 的 `data-id`,或者 URL 中 `-{digits}` 后缀 | | `origin` | 解码后的 `plyURL` | | `title` | `\|` 之前的 `` 标签内容 | **`/get`** (`src/ployan/hls.js`) —— 使用上一步封装好的 token: ``` GET {origin}/get/{token} Referer: {origin}/ ``` **M3U8 URL** —— 当响应包含 `mode: "direct"` 和 `info` 时: ``` {origin}/hls/{info}/master.m3u8 ``` 接下来就是标准的 HLS 流程:`master.m3u8` → 媒体播放列表 → CDN 上的 `.ts` 切片。VLC、MPV 和 Safari 可以原生处理这个链路。本仓库在获取到主 URL 后即停止。其他 `mode` 值不会返回播放列表 (`url: null`)。 ## 为什么无法直接在浏览器中播放 解析出的 M3U8 URL **无需本服务器**即可在 **VLC 和 MPV** 中播放。但无法在浏览器内直接播放。 **HLS 支持** —— 只有 Safari 原生支持播放 HLS。Chrome 和 Firefox 需要 [hls.js](https://github.com/video-dev/hls.js/) (`public/ui.js`)。 **跨域 (Cross-origin)** —— M3U8 和 `.ts` 切片位于 Player CDN 上,而非 UI 的源站。浏览器会拦截或限制这些 fetch 请求(因为 CORS 限制或缺少上游 headers)。 ## 代理原理 `src/route/proxy.js` 处理 `GET /api/proxy?url={absolute-url}` 请求。 1. 发起带有 `Referer: {upstream-origin}/` 头的 fetch 请求访问上游。 2. 如果响应是 M3U8(基于 `Content-Type`、`.m3u8` 路径或 `#EXTM3U` 头部判断): - 将媒体行和 `URI="…"` 标签重写为 `{host}/api/proxy?url={encoded-upstream}`。 3. 否则,原样透传二进制字节流(`.ts`、`.m4s`、密钥)。 浏览器只与你的主机通信。UI 通过代理后的 URL 播放;外部播放器可以直接使用 `/api/stream` 返回的 M3U8 地址。 ## 技术栈 | 角色 | 技术 | | --- | --- | | `plyURL` 解码 | `Buffer` base64 | | Token 封装 | `node:crypto` — `randomBytes`, `pbkdf2Sync`, `createCipheriv("aes-256-gcm")` | | Runtime | Node.js ≥ 22, ES modules | | HTTP | `node:http`, 原生 `fetch` | | 浏览器 HLS (UI) | 来自 jsDelivr 的 hls.js 1.5.20 | | 变量 | 默认值 | 用途 | | --- | --- | --- | | `PORT` | `3000` | 监听端口 | | `HOST` | 所有网络接口 | 设置后进行绑定 | | `USER_AGENT` | Chrome Android 移动版 | 上游服务身份标识 | `npm start` ## 代码结构 | 文件 | 导出 | | --- | --- | | `src/server.js` | HTTP 入口 | | `src/env.js` | `headers()`, `port` | | `src/net/fetch.js` | `text()`, `json()`, `bytes()` | | `src/embed/scrape.js` | `scrape()` | | `src/ployan/hls.js` | `hls()` | | `src/route/stream.js` | `onStream()` | | `src/route/proxy.js` | `onProxy()` | | `public/ui.js` | UI 播放 | ## REST API ### `GET /api/stream` | 参数 | 必需 | 描述 | | --- | --- | --- | | `url` | 是 | 嵌入页面 URL | | `episode` | 否 | Token payload 中的 episode (默认为 `1`) | **`200`** ``` { "title": "Movie Title", "url": "https://player.example/hls/abc123/master.m3u8", "mode": "direct" } ``` 缺少 `url` 查询参数 → `400`。上游请求失败 → `502` `{ "error": "…" }`。CORS: `*`。 ### `GET /api/proxy` | 参数 | 必需 | 描述 | | --- | --- | --- | | `url` | 是 | 上游绝对 URL (M3U8 或切片) | 返回重写后的 M3U8 或原始字节流。 ## 免责声明 本项目不托管、存储或分发任何媒体内容。嵌入站点和 Ployan 播放器主机是独立的服务。该解析器读取公开的嵌入 HTML,并像浏览器一样调用它们的 API。 您有责任遵守版权法、网站服务条款以及当地法规。本项目不提供任何保证。请仅在您有权访问的内容上使用。</div><div><strong>标签:</strong>AES加密, GNU通用公共许可证, HLS, MITM代理, Node.js, 云资产清单, 流媒体解析, 网络测绘, 自定义脚本, 视频代理, 逆向工程</div></article></div> <!-- 人机验证 --> <script> (function () { var base = (document.querySelector('base') && document.querySelector('base').getAttribute('href')) || ''; var path = base.replace(/\/?$/, '') + '/cap-wasm/cap_wasm.min.js'; window.CAP_CUSTOM_WASM_URL = new URL(path, window.location.href).href; })(); </script> </body> </html>