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。
您有责任遵守版权法、网站服务条款以及当地法规。本项目不提供任何保证。请仅在您有权访问的内容上使用。
标签:AES加密, GNU通用公共许可证, HLS, MITM代理, Node.js, 云资产清单, 流媒体解析, 网络测绘, 自定义脚本, 视频代理, 逆向工程