Compare commits
8
Commits
14f7295fd4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fb812a50 | ||
|
|
6b05fde64b | ||
|
|
cec63f864e | ||
|
|
e1357dd412 | ||
|
|
8c02fb5c15 | ||
|
|
ecca1b8458 | ||
|
|
1b972ec4dd | ||
|
|
3381e9b906 |
No files matched your search
@@ -42,6 +42,3 @@ coverage/
|
|||||||
# 以下规则仅作保险,防止旧的发射产物被误生成后提交。
|
# 以下规则仅作保险,防止旧的发射产物被误生成后提交。
|
||||||
src/**/*.js
|
src/**/*.js
|
||||||
sdk/**/*.js
|
sdk/**/*.js
|
||||||
|
|
||||||
# 汽水官方签名原生模块(专有二进制,不入库;QS_MSSDK_DIR 亦可指向别处)
|
|
||||||
soda-mssdk/
|
|
||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
**检查时间**:2026-10-03
|
**检查时间**:2026-10-03
|
||||||
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
|
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
|
||||||
第五节「文档与代码不一致」已按 `/sources` 与路由实现逐条校准;第八节列出的未复核线索与第四节 P2 项**未动**,留待后续。
|
第四节 P2 卫生项已于 2026-10-03 清理完毕(注释死代码 630+ 行、裸 `console.log` 吞错 6 处、未使用符号 26 处清零、bili 状态校验去重);
|
||||||
|
第八节线索逐条复核:qian 导出名、migu 异常消息、Quality `preview`、bili 重试重复均已过时(早前重写时已解决),kw/kg/tx/wy parse/playlist 仅有骨架相似、实质各异,评估后不抽公共层。
|
||||||
|
第五节「文档与代码不一致」已按 `/sources` 与路由实现逐条校准。
|
||||||
各条的修复要点见下文对应条目。
|
各条的修复要点见下文对应条目。
|
||||||
**检查范围**:`src/`(15 文件 / 3,103 行)、`sdk/`(173 文件 / 21,919 行)、`test/`(2 文件 / 1,081 行),共约 26,100 行 TypeScript
|
**检查范围**:`src/`(15 文件 / 3,103 行)、`sdk/`(173 文件 / 21,919 行)、`test/`(2 文件 / 1,081 行),共约 26,100 行 TypeScript
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(migu)、千千音乐(qian)、JOOX(joox)、哔哩哔哩(bili)、5sing;有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
|
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(mg)、千千音乐(bd)、JOOX(joox)、哔哩哔哩(bili)、5sing(5sing);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
|
||||||
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
||||||
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
|
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
|
||||||
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
|
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
|
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
|
||||||
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
|
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
|
||||||
| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
|
| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
|
||||||
| 咪咕音乐 | `migu` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
|
| 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
|
||||||
| 千千音乐 | `qian` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
||||||
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
||||||
| 哔哩哔哩 | `bili` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 |
|
| 哔哩哔哩 | `bili` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 |
|
||||||
| 5sing | `5sing` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) |
|
| 5sing | `5sing` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) |
|
||||||
@@ -52,7 +52,7 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
|
| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
|
||||||
| `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
|
| `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
|
||||||
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
|
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
|
||||||
| `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`migu`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
|
| `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`mg`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
|
||||||
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
|
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
|
||||||
| `GET /pic/:source/:id` | 获取封面 |
|
| `GET /pic/:source/:id` | 获取封面 |
|
||||||
| `GET /boards/:source` | 榜单列表 |
|
| `GET /boards/:source` | 榜单列表 |
|
||||||
@@ -62,13 +62,13 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
|
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
|
||||||
| `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
|
| `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
|
||||||
| `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
|
| `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
|
||||||
| `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/migu/qian/joox/apple |
|
| `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple |
|
||||||
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/migu/qian/joox/apple) |
|
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/joox/apple) |
|
||||||
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/migu/qian/joox/bili/5sing/apple) |
|
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple) |
|
||||||
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple) |
|
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple) |
|
||||||
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
|
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
|
||||||
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple;bili 支持合集/多分P) |
|
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple;bili 支持合集/多分P) |
|
||||||
| `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5sing/apple/bili/joox/qian/migu(其中 bili、5sing 无 album 端点) |
|
| `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5sing/apple/bili/joox/bd/mg(其中 bili、5sing 无 album 端点) |
|
||||||
| `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) |
|
| `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) |
|
||||||
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
|
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
|
||||||
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
|
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
|
||||||
@@ -103,8 +103,8 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
|
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
|
||||||
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
|
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
|
||||||
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
|
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
|
||||||
- **migu**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/migu` 解密代理
|
- **mg**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/mg` 解密代理
|
||||||
- **qian**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
|
- **bd**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
|
||||||
- **joox**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
|
- **joox**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
|
||||||
- **bili**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
|
- **bili**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
|
||||||
- **5sing**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C`
|
- **5sing**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C`
|
||||||
@@ -129,6 +129,19 @@ X-Server-Key: <AUTH_KEY> # 统一验证头(全部路径)
|
|||||||
|
|
||||||
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。
|
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。
|
||||||
|
|
||||||
|
## 汽水 PC 签名(可选增强)
|
||||||
|
|
||||||
|
汽水(`qs`)的 PC 通道(搜索优先走、取流降级走)依赖 `X-Helios`/`X-Medusa` 两个加密签名头,按优先级两种供给方式;都没有时 PC 通道自动关闭,只走安卓自签通道(匿名搜索/试听不受影响):
|
||||||
|
|
||||||
|
1. **签名服务器**:环境变量 `QS_SIGN_SERVER`(如 `http://127.0.0.1:8899`,可选 `QS_SIGN_TOKEN` 配 Bearer 鉴权)。契约兼容独立仓库 **qs-signer**(`gitea.4i.hk/hhhge/qs-signer`,本地与 MusicServer 同级)的两个服务——`server.mjs`(Wine + bdms.node)与 `mssdk-sign`(metasecml.dll 独立进程,见其 README),跨平台部署无需在本机安装官方客户端。
|
||||||
|
2. **固定值**:无签名服务器时,两个加密头直接用抓包固定值,写入 `data/qs-sign.json`(已 gitignore):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`device_id`/`install_id` 必须与抓包时同一套(签名与设备绑定,会自动对齐 URL 参数);也可用环境变量 `QS_HELIOS`/`QS_MEDUSA`(配套 `QS_SIGN_DEVICE_ID`/`QS_SIGN_INSTALL_ID`)。固定值时效自负,被风控时搜索/取流自动回落安卓通道。
|
||||||
|
|
||||||
## 外部音源
|
## 外部音源
|
||||||
|
|
||||||
设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
|
设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
|
||||||
@@ -196,7 +209,7 @@ test/
|
|||||||
- **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
|
- **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
|
||||||
- **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
|
- **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
|
||||||
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
|
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
|
||||||
- **migu 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/migu` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
|
- **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
|
||||||
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
|
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
|
||||||
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
||||||
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
|
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
|
||||||
|
|||||||
+4
-4
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
||||||
|
|
||||||
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `migu`(咪咕)/
|
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/
|
||||||
`qian`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
|
`bd`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
|
||||||
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
||||||
|
|
||||||
> **本文档是人工速查版,可能滞后于代码。**
|
> **本文档是人工速查版,可能滞后于代码。**
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
### 歌曲 id 形态
|
### 歌曲 id 形态
|
||||||
|
|
||||||
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`migu` 的三元组、
|
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`mg` 的三元组、
|
||||||
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
|
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
|
||||||
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
|
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
||||||
| GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `migu` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
|
| GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
|
||||||
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
|
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
|
||||||
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
|
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
"start": "node src/server.ts",
|
"start": "node src/server.ts",
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/smoke-test.ts"
|
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/bili-cdn-test.ts && node test/smoke-test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// 汽水音乐「本机客户端登录」独立脚本 —— 不参与服务运行,需要时手动跑一次
|
|
||||||
//
|
|
||||||
// 做什么:官方 SodaMusic PC 客户端是 Chromium,登录态就存在本机
|
|
||||||
// %APPDATA%\SodaMusic\Network\Cookies(SQLite)里,设备身份在 %APPDATA%\SodaMusic\DeviceV1
|
|
||||||
// (gzip JSON,含 did/iid)。本脚本直接读出来写进 data/auth.json 的 qs 字段,
|
|
||||||
// 免去扫码登录。参考实现:YYF1337/PopDownloader(SaKongA/PopDownloader 的 fork)。
|
|
||||||
//
|
|
||||||
// 用法:
|
|
||||||
// node scripts/qs-local-login.mjs # 读取 + 校验 + 写入 data/auth.json
|
|
||||||
// node scripts/qs-local-login.mjs --print # 只打印 sessionid,不落盘
|
|
||||||
// node scripts/qs-local-login.mjs --no-verify # 跳过 /luna/pc/me 校验
|
|
||||||
// node scripts/qs-local-login.mjs --dir <目录> # 指定客户端数据目录(默认 %APPDATA%\SodaMusic)
|
|
||||||
// node scripts/qs-local-login.mjs --auth <文件> # 指定 auth.json(默认 <仓库>/data/auth.json)
|
|
||||||
//
|
|
||||||
// 校验需要官方签名模块(soda-mssdk/bdms.node 或 QS_MSSDK_DIR),没有就跳过校验只写入。
|
|
||||||
// 注意:服务在跑时 auth.json 已缓存,写完需重启服务才生效(脚本会提示)。
|
|
||||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
||||||
import { createRequire } from 'node:module'
|
|
||||||
import { tmpdir } from 'node:os'
|
|
||||||
import { dirname, join, resolve } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { gunzipSync } from 'node:zlib'
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2)
|
|
||||||
const flag = (name) => argv.includes(name)
|
|
||||||
const value = (name) => {
|
|
||||||
const i = argv.indexOf(name)
|
|
||||||
return i >= 0 ? argv[i + 1] : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataDir = resolve(value('--dir') || process.env.QS_SODA_DATA_DIR || join(process.env.APPDATA || '', 'SodaMusic'))
|
|
||||||
const authPath = resolve(value('--auth') || join(root, 'data', 'auth.json'))
|
|
||||||
const printOnly = flag('--print')
|
|
||||||
const skipVerify = flag('--no-verify')
|
|
||||||
const DEVICE_FILE = join(dataDir, 'DeviceV1')
|
|
||||||
const COOKIE_DB = join(dataDir, 'Network', 'Cookies')
|
|
||||||
|
|
||||||
const die = (msg) => {
|
|
||||||
console.error(`✗ ${msg}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 1. 客户端设备身份(DeviceV1:gzip JSON 的 {did,iid})——
|
|
||||||
const readDevice = () => {
|
|
||||||
if (!existsSync(DEVICE_FILE)) return null
|
|
||||||
const raw = readFileSync(DEVICE_FILE)
|
|
||||||
let text
|
|
||||||
try {
|
|
||||||
text = gunzipSync(raw).toString('utf8')
|
|
||||||
} catch {
|
|
||||||
text = raw.toString('utf8')
|
|
||||||
}
|
|
||||||
const data = JSON.parse(text)
|
|
||||||
return data?.did && data?.iid ? { did: String(data.did), iid: String(data.iid) } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 2. 客户端登录态(Chromium cookie 库,复制副本后只读查询)——
|
|
||||||
const readSessionId = () => {
|
|
||||||
if (!existsSync(COOKIE_DB)) die(`未找到客户端登录数据:${COOKIE_DB}\n 请先安装并登录官方汽水音乐 PC 客户端,或用 --dir 指定数据目录`)
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'qs-cookies-'))
|
|
||||||
try {
|
|
||||||
const copy = join(tempDir, 'Cookies')
|
|
||||||
copyFileSync(COOKIE_DB, copy)
|
|
||||||
for (const side of ['-wal', '-shm']) {
|
|
||||||
if (existsSync(COOKIE_DB + side)) copyFileSync(COOKIE_DB + side, copy + side)
|
|
||||||
}
|
|
||||||
const { DatabaseSync } = require('node:sqlite')
|
|
||||||
const db = new DatabaseSync(copy, { readOnly: true })
|
|
||||||
try {
|
|
||||||
const row = db.prepare(
|
|
||||||
"SELECT value FROM cookies WHERE host_key IN ('.qishui.com', 'qishui.com') AND name = 'sessionid' LIMIT 1",
|
|
||||||
).get()
|
|
||||||
return String(row?.value ?? '').trim()
|
|
||||||
} finally {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (/EBUSY|locked|busy|unable to open/i.test(String(err?.message))) {
|
|
||||||
die('客户端正在运行且 cookie 库被占用,请退出汽水音乐客户端后重试')
|
|
||||||
}
|
|
||||||
die(`读取 cookie 库失败: ${err?.message ?? err}`)
|
|
||||||
} finally {
|
|
||||||
rmSync(tempDir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 3. 可选:签名打 /luna/pc/me 校验(status_code === 0 视为有效)——
|
|
||||||
const findSigner = () => {
|
|
||||||
const dirs = [process.env.QS_MSSDK_DIR, join(root, 'soda-mssdk')].filter(Boolean)
|
|
||||||
for (const dir of dirs) {
|
|
||||||
if (existsSync(join(dir, 'bdms.node'))) return join(dir, 'bdms.node')
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const verify = async (sessionid, device) => {
|
|
||||||
const modulePath = findSigner()
|
|
||||||
if (!modulePath) return null
|
|
||||||
try {
|
|
||||||
const bdms = require(modulePath)
|
|
||||||
bdms.init({ deviceId: device.did })
|
|
||||||
const query = new URLSearchParams({
|
|
||||||
aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '',
|
|
||||||
device_id: device.did, cdid: '', iid: device.iid, version_name: '3.7.0', version_code: '30070000',
|
|
||||||
channel: 'official', build_mode: 'master', network_carrier: '', ac: 'wifi', tz_name: 'Asia/Shanghai',
|
|
||||||
resolution: '', device_platform: 'windows', device_type: 'Windows', os_version: 'Windows 11', fp: device.did,
|
|
||||||
})
|
|
||||||
const url = `https://api.qishui.com/luna/pc/me?${query}`
|
|
||||||
const headers = { 'user-agent': 'LunaPC/3.7.0(452316191)', cookie: `sessionid=${sessionid}` }
|
|
||||||
const lines = Object.entries(headers).map(([k, v]) => `${k}\r\n${v}`).join('\r\n')
|
|
||||||
const parts = String(bdms.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(t => t.trim())
|
|
||||||
for (let i = 0; i + 1 < parts.length; i += 2) headers[parts[i]] = parts[i + 1]
|
|
||||||
const res = await fetch(url, { headers })
|
|
||||||
if (res.status !== 200) return null
|
|
||||||
const body = await res.json().catch(() => null)
|
|
||||||
const code = body?.status_code
|
|
||||||
if (code === 0) return true
|
|
||||||
return code == null ? null : false
|
|
||||||
} catch (err) {
|
|
||||||
console.log(`! 校验失败(按未校验处理): ${err?.message ?? err}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const device = readDevice()
|
|
||||||
if (!device) console.log(`! 未读到客户端设备身份(${DEVICE_FILE}),校验时将跳过`)
|
|
||||||
else console.log(`· 客户端设备: did=${device.did} iid=${device.iid}`)
|
|
||||||
|
|
||||||
const sessionid = readSessionId()
|
|
||||||
if (!sessionid) die('客户端未登录(cookie 库中没有 .qishui.com 的 sessionid),请先在客户端登录')
|
|
||||||
|
|
||||||
let verified = null
|
|
||||||
if (!skipVerify && device) {
|
|
||||||
verified = await verify(sessionid, device)
|
|
||||||
if (verified === false) die('客户端登录态已失效,请在汽水音乐客户端重新登录后再跑本脚本')
|
|
||||||
}
|
|
||||||
console.log(`· sessionid: ${sessionid.slice(0, 6)}…${sessionid.slice(-4)}(${sessionid.length} 字符)`)
|
|
||||||
console.log(`· 校验: ${verified === true ? '有效' : verified === false ? '已失效' : skipVerify ? '已跳过(--no-verify)' : '无法判定(缺签名模块或网络异常)'}`)
|
|
||||||
|
|
||||||
if (printOnly) {
|
|
||||||
console.log(sessionid)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = existsSync(authPath) ? JSON.parse(readFileSync(authPath, 'utf8')) : {}
|
|
||||||
auth.qs = { ...(auth.qs || {}), sessionid, cookie: `sessionid=${sessionid}`, source: 'local-client', updatedAt: new Date().toISOString() }
|
|
||||||
writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`)
|
|
||||||
console.log(`✓ 已写入 ${authPath}(qs.sessionid)`)
|
|
||||||
console.log(' 服务在运行的话需要重启才生效(凭据已缓存);之后 /login/status 的 qs.logged 应为 true')
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => die(err?.message ?? String(err)))
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
|
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
|
||||||
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
|
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
|
export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL)
|
// - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL)
|
||||||
// - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链
|
// - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链
|
||||||
// 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位)
|
// 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位)
|
||||||
import { apiOk, firstNonEmpty, trackLink } from '../qian/util.ts'
|
import { apiOk, firstNonEmpty, trackLink } from '../bd/util.ts'
|
||||||
|
|
||||||
// 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
|
// 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
|
||||||
const QUALITY_RATES: Record<string, string[]> = {
|
const QUALITY_RATES: Record<string, string[]> = {
|
||||||
+113
-36
@@ -4,9 +4,14 @@
|
|||||||
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
|
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
|
||||||
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
|
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
|
||||||
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
|
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
|
||||||
// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用),
|
//
|
||||||
// 失败时回退原始地址,保证客户端仍能自行跟跳。
|
// CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
|
||||||
// 参考 Referer/UA 必须携带,否则 CDN 可能 403。
|
// 迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
|
||||||
|
// 正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
|
||||||
|
// cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
|
||||||
|
// 因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
|
||||||
|
// 逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
|
||||||
|
// 参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
|
||||||
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
|
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
|
||||||
@@ -22,6 +27,11 @@ const QUALITY_LABEL: Record<number, string> = {
|
|||||||
30251: 'flac24bit',
|
30251: 'flac24bit',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
|
||||||
|
const PROBE_HOP_TIMEOUT = 8000
|
||||||
|
const PROBE_TOTAL_BUDGET = 20000
|
||||||
|
const PROBE_MAX_CANDIDATES = 3
|
||||||
|
|
||||||
let vipCache: { value: boolean, at: number } | null = null
|
let vipCache: { value: boolean, at: number } | null = null
|
||||||
|
|
||||||
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
|
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
|
||||||
@@ -47,34 +57,72 @@ export const isVipAccount = async(cookie: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickAudio = (data: any, type: string): { id: number, url: string, label: string } | null => {
|
/** CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / upos-*.akamaized.net,多地节点)
|
||||||
|
* 1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方) */
|
||||||
|
const hostRank = (url: string): number => {
|
||||||
|
let host = ''
|
||||||
|
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
|
||||||
|
if (host.endsWith('.akamaized.net')) return 0
|
||||||
|
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点地址收集:baseUrl + backupUrl(去重) */
|
||||||
|
const nodeUrls = (node: any): string[] => {
|
||||||
|
const backups = [
|
||||||
|
...(Array.isArray(node?.backupUrl) ? node.backupUrl : []),
|
||||||
|
...(Array.isArray(node?.backup_url) ? node.backup_url : []),
|
||||||
|
]
|
||||||
|
return [node?.baseUrl, ...backups].filter(Boolean).map(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先) */
|
||||||
|
const rankCandidates = (urls: string[]): string[] => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const uniq: string[] = []
|
||||||
|
for (const url of urls) {
|
||||||
|
const u = String(url || '')
|
||||||
|
if (!u || seen.has(u)) continue
|
||||||
|
seen.add(u)
|
||||||
|
uniq.push(u)
|
||||||
|
}
|
||||||
|
return uniq
|
||||||
|
.map((u, i) => ({ u, i, rank: hostRank(u) }))
|
||||||
|
.sort((a, b) => a.rank - b.rank || a.i - b.i)
|
||||||
|
.map(x => x.u)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickAudio = (data: any, type: string): { id: number, label: string, urls: string[] } | null => {
|
||||||
const dash = data?.dash ?? {}
|
const dash = data?.dash ?? {}
|
||||||
const flac = dash?.flac?.audio
|
const flac = dash?.flac?.audio
|
||||||
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? { id: 30251, url: String(flac.baseUrl) } : null
|
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? flac : null
|
||||||
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
|
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
|
||||||
const standards: any[] = (dash?.audio ?? [])
|
const standards: any[] = (dash?.audio ?? [])
|
||||||
.filter((a: any) => a?.baseUrl)
|
.filter((a: any) => a?.baseUrl)
|
||||||
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
|
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
|
||||||
|
|
||||||
|
let node: any = null
|
||||||
|
let label = ''
|
||||||
if (type === '64k') {
|
if (type === '64k') {
|
||||||
const low = standards.find(a => Number(a.id) === 30216)
|
const low = standards.find(a => Number(a.id) === 30216)
|
||||||
if (low) return { id: 30216, url: String(low.baseUrl), label: QUALITY_LABEL[30216] }
|
if (low) { node = low; label = QUALITY_LABEL[30216] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
|
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
|
||||||
if (hires) return { ...hires, label: QUALITY_LABEL[30251] }
|
if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] }
|
||||||
if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] }
|
if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] }
|
||||||
if (standards.length) {
|
if (!node && standards.length) {
|
||||||
const best = standards[0]
|
node = standards[0]
|
||||||
return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` }
|
label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}`
|
||||||
}
|
}
|
||||||
const durl = data?.durl?.[0]?.url
|
if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) }
|
||||||
if (durl) return { id: 0, url: String(durl), label: 'durl' }
|
|
||||||
|
const durlNode = data?.durl?.[0]
|
||||||
|
if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */
|
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */
|
||||||
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => {
|
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: string[] }> => {
|
||||||
const cookie = await getCookie()
|
const cookie = await getCookie()
|
||||||
const needFlac = type === 'flac' || type === 'flac24bit'
|
const needFlac = type === 'flac' || type === 'flac24bit'
|
||||||
const vip = await isVipAccount(cookie)
|
const vip = await isVipAccount(cookie)
|
||||||
@@ -84,36 +132,53 @@ export const getAudioUrl = async(bvid: string, cid: string | number, type = '128
|
|||||||
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`)
|
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`)
|
||||||
if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
|
if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
|
||||||
const picked = pickAudio(body.data, type)
|
const picked = pickAudio(body.data, type)
|
||||||
if (!picked) throw new Error('B站未返回可用音频流')
|
if (!picked || !picked.urls.length) throw new Error('B站未返回可用音频流')
|
||||||
return { type: picked.label, url: picked.url }
|
return { type: picked.label, urls: picked.urls }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */
|
/**
|
||||||
const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise<string> => {
|
* 探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开 body。
|
||||||
|
* HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
|
||||||
|
* 会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。
|
||||||
|
* 命中 2xx/206 返回该地址;4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
|
||||||
|
*/
|
||||||
|
const resolveUrl = async(url: string, tracker: { current: any, cancelled?: boolean }, deadline: number): Promise<string> => {
|
||||||
let current = String(url)
|
let current = String(url)
|
||||||
for (let hop = 0; hop < 3; hop++) {
|
for (let hop = 0; hop < 3; hop++) {
|
||||||
let response: any
|
const remaining = deadline - Date.now()
|
||||||
|
if (remaining <= 0) throw new Error('CDN 探测超时(总预算耗尽)')
|
||||||
|
const controller = new AbortController()
|
||||||
|
tracker.current = controller
|
||||||
|
const timer = setTimeout(() => controller.abort(new Error('CDN 探测超时')), Math.min(PROBE_HOP_TIMEOUT, remaining))
|
||||||
|
timer.unref?.()
|
||||||
|
let resp: Response
|
||||||
try {
|
try {
|
||||||
const request = httpFetch(current, {
|
resp = await fetch(current, {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' },
|
headers: { ...biliHeaders(), Range: 'bytes=0-0' },
|
||||||
timeout: 15000,
|
redirect: 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (tracker) tracker.current = request
|
} catch (err: any) {
|
||||||
response = await request.promise
|
if (tracker.cancelled) { err.canceled = true; throw err }
|
||||||
} catch {
|
throw err
|
||||||
return current
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
const statusCode = Number(response?.statusCode) || 0
|
// 拿到响应头即断开,不消费 body
|
||||||
const location = response?.headers?.location
|
await resp.body?.cancel().catch(() => {})
|
||||||
if (statusCode >= 300 && statusCode < 400 && location) {
|
const status = resp.status
|
||||||
|
const location = resp.headers.get('location')
|
||||||
|
if (status >= 300 && status < 400 && location) {
|
||||||
try {
|
try {
|
||||||
current = new URL(String(location), current).href
|
current = new URL(location, current).href
|
||||||
continue
|
continue
|
||||||
} catch {
|
} catch {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (status >= 200 && status < 300) return current
|
||||||
|
if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`)
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
@@ -123,13 +188,15 @@ const followRedirects = async(url: string, cookie: string, tracker?: { current:
|
|||||||
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
|
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
|
||||||
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
|
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
|
||||||
|
* CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
|
||||||
*/
|
*/
|
||||||
export const getMusicUrl = (songInfo: any, type: string) => {
|
export const getMusicUrl = (songInfo: any, type: string) => {
|
||||||
const tracker: { current: any } = { current: null }
|
const tracker: { current: any, cancelled?: boolean } = { current: null }
|
||||||
const requestObj: any = {
|
const requestObj: any = {
|
||||||
cancelHttp() {
|
cancelHttp() {
|
||||||
|
tracker.cancelled = true
|
||||||
try {
|
try {
|
||||||
tracker.current?.cancelHttp?.()
|
tracker.current?.abort?.(new Error('Canceled'))
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -147,9 +214,19 @@ export const getMusicUrl = (songInfo: any, type: string) => {
|
|||||||
}
|
}
|
||||||
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
|
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
|
||||||
const info = await getAudioUrl(bvid, realCid, type)
|
const info = await getAudioUrl(bvid, realCid, type)
|
||||||
const cookie = await getCookie()
|
|
||||||
const finalUrl = await followRedirects(info.url, cookie, tracker)
|
const deadline = Date.now() + PROBE_TOTAL_BUDGET
|
||||||
|
for (const candidate of info.urls.slice(0, PROBE_MAX_CANDIDATES)) {
|
||||||
|
try {
|
||||||
|
const finalUrl = await resolveUrl(candidate, tracker, deadline)
|
||||||
return { type: info.type, url: finalUrl }
|
return { type: info.type, url: finalUrl }
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.canceled || tracker.cancelled) throw new Error('canceled')
|
||||||
|
if (Date.now() >= deadline) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 探测全败(或预算耗尽):回退分级排序后的最优候选
|
||||||
|
return { type: info.type, url: info.urls[0] }
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return requestObj
|
return requestObj
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
// 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
// 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
|
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { jooxHeaders, normalizeJooxID } from '../joox/util.ts'
|
import { jooxHeaders, normalizeJooxID } from '../joox/util.ts'
|
||||||
import { buildTrackUrl } from '../joox/musicInfo.ts'
|
import { buildTrackUrl } from '../joox/musicInfo.ts'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
|
// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
|
||||||
// 成功响应 list[0].path 为音频直链。
|
// 成功响应 list[0].path 为音频直链。
|
||||||
// songmid 为复合 id "<bookId>:<section>"(/album/lr 章节列表产出);纯 bookId 视为第一章
|
// songmid 为复合 id "<bookId>:<section>"(/album/lr 章节列表产出);纯 bookId 视为第一章
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getLrCookie } from '../lr/auth.ts'
|
import { getLrCookie } from '../lr/auth.ts'
|
||||||
|
|
||||||
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
|
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ const pickUrl = (info: any, type: string) => {
|
|||||||
|
|
||||||
/** 剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[]) */
|
/** 剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[]) */
|
||||||
const resolveDramaFirstFree = async(dramaId: string): Promise<string> => {
|
const resolveDramaFirstFree = async(dramaId: string): Promise<string> => {
|
||||||
const { httpFetch } = await import('../request.ts')
|
const { httpFetch } = await import('../common/request.ts')
|
||||||
const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/getdrama?drama_id=${dramaId}`, {
|
const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/getdrama?drama_id=${dramaId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
// 命中可访问的最高档位;匿名仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie。
|
// 命中可访问的最高档位;匿名仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie。
|
||||||
// 上游对应:music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
|
// 上游对应:music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
|
||||||
// selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)
|
// selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import {
|
import {
|
||||||
AndroidChannel, AndroidUA, AndroidVersion, buildUrl, decryptZ3D, firstNonEmpty,
|
AndroidChannel, AndroidUA, AndroidVersion, decryptZ3D, firstNonEmpty,
|
||||||
getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem,
|
getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem,
|
||||||
} from '../migu/util.ts'
|
} from '../mg/util.ts'
|
||||||
|
|
||||||
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
||||||
|
|
||||||
@@ -112,15 +112,6 @@ const normalizeDownloadURL = (rawURL?: string | null): string => {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEncryptedDirectURL = (rawURL: string): boolean => {
|
|
||||||
try {
|
|
||||||
const decodedPath = decodeURIComponent(new URL(rawURL).pathname)
|
|
||||||
return decodedPath.includes('wav_3d/') && !decodedPath.includes('wav_3d_60s/')
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadInfo {
|
interface DownloadInfo {
|
||||||
url: string
|
url: string
|
||||||
format: string
|
format: string
|
||||||
@@ -298,7 +289,7 @@ const MAX_PROBES = 10
|
|||||||
export interface MiguUrlResult {
|
export interface MiguUrlResult {
|
||||||
type: string
|
type: string
|
||||||
url: string
|
url: string
|
||||||
/** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/migu 代理) */
|
/** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/mg 代理) */
|
||||||
encrypted?: boolean
|
encrypted?: boolean
|
||||||
fileKey?: string
|
fileKey?: string
|
||||||
ext?: string
|
ext?: string
|
||||||
+9
-75
@@ -1,21 +1,20 @@
|
|||||||
// 汽水音乐 URL 获取(直连)
|
// 汽水音乐 URL 获取(直连)
|
||||||
// 两条取流通道:
|
// 两条取流通道:
|
||||||
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
|
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
|
||||||
// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr)
|
// 登录态: 官方 PC 签名 X-Helios/X-Medusa(见 sdk/qs/mssdk.ts)→ /luna/pc/track_v2
|
||||||
// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin)
|
// → 加密音频(cenc-aes-ctr)→ spade key 解密 → /stream/qs/:id 代理端点回明文流
|
||||||
|
// (相对地址,路由层负责补全 origin)
|
||||||
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
|
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
|
||||||
// 登录请求失败自动降级匿名试听。
|
// 登录请求失败自动降级匿名试听。
|
||||||
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
// promise resolve → { type, url }
|
// promise resolve → { type, url }
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { createHash, randomBytes } from 'node:crypto'
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
import musicInfo from '../qs/musicInfo.ts'
|
import musicInfo from '../qs/musicInfo.ts'
|
||||||
import { getDevice } from '../qs/device.ts'
|
|
||||||
import { getCookie, getSessionId } from '../qs/passport.ts'
|
import { getCookie, getSessionId } from '../qs/passport.ts'
|
||||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
|
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
|
||||||
import { signRequest } from '../qs/crypto/sign.ts'
|
import { decryptAudioBuffer, resolveAudioKey } from '../qs/audiodecrypt.ts'
|
||||||
import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts'
|
|
||||||
import type { HttpResponse } from '../types.ts'
|
import type { HttpResponse } from '../types.ts'
|
||||||
|
|
||||||
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
|
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
|
||||||
@@ -76,43 +75,7 @@ const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频
|
// 登录态通道:官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
|
||||||
const buildSignedUrl = (device: Awaited<ReturnType<typeof getDevice>>): string => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
device_platform: 'android',
|
|
||||||
os: 'android',
|
|
||||||
ssmix: 'a',
|
|
||||||
cdid: device.cdid,
|
|
||||||
channel: device.channel,
|
|
||||||
aid: '386088',
|
|
||||||
app_name: 'luna',
|
|
||||||
version_code: '100198030',
|
|
||||||
version_name: '19.8.0',
|
|
||||||
manifest_version_code: '100198030',
|
|
||||||
update_version_code: '100198030',
|
|
||||||
resolution: '1080*1920',
|
|
||||||
dpi: '480',
|
|
||||||
device_type: device.device_type,
|
|
||||||
device_brand: device.device_brand,
|
|
||||||
language: 'zh',
|
|
||||||
os_api: '35',
|
|
||||||
os_version: device.os_version,
|
|
||||||
ac: 'wifi',
|
|
||||||
device_model: device.device_model,
|
|
||||||
device_id: device.device_id,
|
|
||||||
iid: device.install_id,
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
tz_offset: '28800',
|
|
||||||
sim_region: 'cn',
|
|
||||||
package: 'com.luna.music',
|
|
||||||
luna_apk_type: 'normal_apk',
|
|
||||||
output_device_type: 'Phone',
|
|
||||||
openudid: device.openudid,
|
|
||||||
_rticket: String(Date.now()),
|
|
||||||
})
|
|
||||||
return `${TRACK_V2_URL}?${params.toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickBestEntry = (playInfoList: any) => {
|
const pickBestEntry = (playInfoList: any) => {
|
||||||
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
|
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
|
||||||
if (!entries.length) return null
|
if (!entries.length) return null
|
||||||
@@ -138,28 +101,9 @@ const fetchTrackPlayer = async (url: string, headers: Record<string, string>, re
|
|||||||
const TRACK_V2_BODY = (trackId: string) =>
|
const TRACK_V2_BODY = (trackId: string) =>
|
||||||
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||||
|
|
||||||
// 通道 A(默认):安卓 App 参数 + X-Argus/X-Ladon 自签
|
|
||||||
const fetchTrackPlayerByApp = async (trackId: string) => {
|
|
||||||
const device = await getDevice()
|
|
||||||
const sessionId = getSessionId()
|
|
||||||
const url = buildSignedUrl(device)
|
|
||||||
const requestBody = TRACK_V2_BODY(trackId)
|
|
||||||
const headers = await signRequest(url, requestBody)
|
|
||||||
return fetchTrackPlayer(url, {
|
|
||||||
...headers,
|
|
||||||
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
'X-Luna-Background-Type': 'foreground',
|
|
||||||
'X-Luna-Is-Background-Req': '0',
|
|
||||||
'X-Luna-Is-Local-User': '1',
|
|
||||||
...(sessionId ? { Cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
|
||||||
}, requestBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(需 sdk/qs/mssdk.ts 的签名模块)
|
|
||||||
const fetchTrackPlayerByPc = async (trackId: string) => {
|
const fetchTrackPlayerByPc = async (trackId: string) => {
|
||||||
const sessionId = getSessionId()
|
const sessionId = getSessionId()
|
||||||
if (!mssdkAvailable()) throw new Error('官方签名模块不可用')
|
if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)')
|
||||||
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
|
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
|
||||||
const requestBody = TRACK_V2_BODY(trackId)
|
const requestBody = TRACK_V2_BODY(trackId)
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -172,23 +116,13 @@ const fetchTrackPlayerByPc = async (trackId: string) => {
|
|||||||
'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(),
|
'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(),
|
||||||
...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
||||||
}
|
}
|
||||||
const signed = signQishuiRequest(url, headers)
|
const signed = await signQishuiRequest(url, headers, requestBody)
|
||||||
if (!signed) throw new Error('官方签名失败')
|
if (!signed) throw new Error('官方签名失败')
|
||||||
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
|
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveTrackPlayer = async (trackId: string) => {
|
|
||||||
try {
|
|
||||||
return await fetchTrackPlayerByApp(trackId)
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!mssdkAvailable()) throw err
|
|
||||||
console.log(`[qs/track_v2] 安卓自签通道失败(${err?.message ?? err}),降级官方 PC 签名`)
|
|
||||||
return fetchTrackPlayerByPc(trackId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
||||||
const trackPlayer = await resolveTrackPlayer(trackId)
|
const trackPlayer = await fetchTrackPlayerByPc(trackId)
|
||||||
|
|
||||||
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
|
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
|
||||||
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
|
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
// - ampGet/ampHeaders:amp-api 请求(httpFetch 不自动跟 302,首页跳转用 fetchFollow 手动跟)
|
// - ampGet/ampHeaders:amp-api 请求(httpFetch 不自动跟 302,首页跳转用 fetchFollow 手动跟)
|
||||||
// - 模型转换:appleSongFromCatalog / appleAlbumToPlaylist / applePlaylistToPlaylist(字段映射同上游)
|
// - 模型转换:appleSongFromCatalog / appleAlbumToPlaylist / applePlaylistToPlaylist(字段映射同上游)
|
||||||
// - ID 提取:extractAppleID / extractAppleIDFromPath / isAppleRawID(apple_test.go 的断言全部逐条保留)
|
// - ID 提取:extractAppleID / extractAppleIDFromPath / isAppleRawID(apple_test.go 的断言全部逐条保留)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime } from '../common/utils.ts'
|
import { formatPlayTime } from '../common/utils.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { generateSimulate } from './kgSimulate.ts'
|
|||||||
import type { KgCredentials } from './store.ts'
|
import type { KgCredentials } from './store.ts'
|
||||||
|
|
||||||
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
|
const md5 = s => crypto.createHash('md5').update(s).digest('hex')
|
||||||
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
|
|
||||||
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
|
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
|
||||||
const APPID = 1005
|
const APPID = 1005
|
||||||
const CLIENTVER = 11430
|
const CLIENTVER = 11430
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} }
|
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_passport_device: {...} }
|
||||||
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
|
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
|
||||||
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
|
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
|
||||||
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
||||||
|
|||||||
@@ -113,6 +113,28 @@ export const cellphoneLogin = async({ phone, password, captcha, countrycode = '8
|
|||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动绑定 cookie(从已登录的 music.163.com 浏览器请求头复制,须含 MUSIC_U)。
|
||||||
|
* 绑定前用 account/get 实测校验,失效直接报 401,与 kg 绑定 token 同款语义。
|
||||||
|
*/
|
||||||
|
export const loginByCookie = async(cookieInput: string) => {
|
||||||
|
const cookie = String(cookieInput || '').trim()
|
||||||
|
const musicU = /MUSIC_U=([^;\s]+)/.exec(cookie)?.[1]
|
||||||
|
if (!musicU) throw Object.assign(new Error('cookie 需包含 MUSIC_U 字段'), { status: 422 })
|
||||||
|
const { body } = await requestWeapi('/api/w/nuser/account/get', {}, cookie)
|
||||||
|
if (!body?.account?.id) {
|
||||||
|
throw Object.assign(new Error('cookie 无效或已过期(MUSIC_U 校验未通过)'), { status: 401 })
|
||||||
|
}
|
||||||
|
const profile = {
|
||||||
|
userid: body.account.id,
|
||||||
|
nickname: body.profile?.nickname ?? null,
|
||||||
|
avatar: body.profile?.avatarUrl ?? null,
|
||||||
|
vipType: body.profile?.vipType ?? null,
|
||||||
|
}
|
||||||
|
setCredentials('wy', { cookie, musicU, ...profile })
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
/** 当前账号资料(未登录返回 null) */
|
/** 当前账号资料(未登录返回 null) */
|
||||||
export const status = async() => {
|
export const status = async() => {
|
||||||
if (!getWyCookie()) return null
|
if (!getWyCookie()) return null
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const mapAlbumSummary = (item: QianAlbumSearchItem) => {
|
|||||||
genre: String(item?.genre ?? '').trim(),
|
genre: String(item?.genre ?? '').trim(),
|
||||||
lang: String(item?.lang ?? '').trim(),
|
lang: String(item?.lang ?? '').trim(),
|
||||||
link: id ? albumLink(id) : '',
|
link: id ? albumLink(id) : '',
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Pr
|
|||||||
/** 搜索专辑(返回歌单/专辑统一摘要形状) */
|
/** 搜索专辑(返回歌单/专辑统一摘要形状) */
|
||||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||||
const word = String(keyword ?? '').trim()
|
const word = String(keyword ?? '').trim()
|
||||||
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
|
const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
|
||||||
if (!word) return empty
|
if (!word) return empty
|
||||||
|
|
||||||
const items = await searchAlbumItems(word, page, limit)
|
const items = await searchAlbumItems(word, page, limit)
|
||||||
@@ -82,7 +82,7 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
|||||||
.map(mapAlbumSummary)
|
.map(mapAlbumSummary)
|
||||||
.filter((album) => album.id)
|
.filter((album) => album.id)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
return { source: 'qian' as const, list, total: items.length, page, limit }
|
return { source: 'bd' as const, list, total: items.length, page, limit }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
|
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
|
||||||
@@ -146,7 +146,7 @@ export const getAlbumDetail = async(id: string, page = 1, limit = 30) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
info: albumInfo,
|
info: albumInfo,
|
||||||
list: paged,
|
list: paged,
|
||||||
page,
|
page,
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay;
|
// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay;
|
||||||
// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表;
|
// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表;
|
||||||
// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。
|
// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。
|
||||||
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选)。
|
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选)。
|
||||||
import qianApi from '../apis/qian.ts'
|
import bdApi from '../apis/bd.ts'
|
||||||
import musicSearch from './musicSearch.ts'
|
import musicSearch from './musicSearch.ts'
|
||||||
import lyric from './lyric.ts'
|
import lyric from './lyric.ts'
|
||||||
import { getSongInfo } from './musicInfo.ts'
|
import { getSongInfo } from './musicInfo.ts'
|
||||||
@@ -16,11 +16,11 @@ import playlist from './playlist.ts'
|
|||||||
import parse from './parse.ts'
|
import parse from './parse.ts'
|
||||||
import { getQianCookie } from './util.ts'
|
import { getQianCookie } from './util.ts'
|
||||||
|
|
||||||
const qian = {
|
const bd = {
|
||||||
musicSearch,
|
musicSearch,
|
||||||
|
|
||||||
getMusicUrl(songInfo, type) {
|
getMusicUrl(songInfo, type) {
|
||||||
return qianApi.getMusicUrl(songInfo, type)
|
return bdApi.getMusicUrl(songInfo, type)
|
||||||
},
|
},
|
||||||
|
|
||||||
getLyric(songInfo) {
|
getLyric(songInfo) {
|
||||||
@@ -83,4 +83,4 @@ export const status = () => {
|
|||||||
return cookie ? { logged: true } : { logged: false }
|
return cookie ? { logged: true } : { logged: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default qian
|
export default bd
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// 千千音乐歌词
|
// 千千音乐歌词
|
||||||
// 链路:/v1/song/info → data[0].lyric 为歌词文本 URL(static-qianqian.taihe.com/*.txt)→ 直接下载文本。
|
// 链路:/v1/song/info → data[0].lyric 为歌词文本 URL(static-qianqian.taihe.com/*.txt)→ 直接下载文本。
|
||||||
// 返回 { lyric, tlyric: '' }(千千只有原文歌词,无翻译)。
|
// 返回 { lyric, tlyric: '' }(千千只有原文歌词,无翻译)。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getSongInfo } from './musicInfo.ts'
|
import { getSongInfo } from './musicInfo.ts'
|
||||||
import { requestHeaders } from './util.ts'
|
import { requestHeaders } from './util.ts'
|
||||||
|
|
||||||
File renamed without changes.
@@ -6,7 +6,7 @@ import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
|
|||||||
|
|
||||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||||
const word = String(keyword ?? '').trim()
|
const word = String(keyword ?? '').trim()
|
||||||
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'qian' as const }
|
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'bd' as const }
|
||||||
if (!word) return empty
|
if (!word) return empty
|
||||||
|
|
||||||
const body = await apiGet('/v1/search', {
|
const body = await apiGet('/v1/search', {
|
||||||
@@ -28,7 +28,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
|||||||
allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1),
|
allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1),
|
||||||
total,
|
total,
|
||||||
limit,
|
limit,
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)
|
// 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)
|
||||||
// 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid=
|
// 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid=
|
||||||
// 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*id= / 裸歌单 id
|
// 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*id= / 裸歌单 id
|
||||||
import { getDownloadUrl } from '../apis/qian.ts'
|
import { getDownloadUrl } from '../apis/bd.ts'
|
||||||
import { getSongInfo } from './musicInfo.ts'
|
import { getSongInfo } from './musicInfo.ts'
|
||||||
import { parseAlbumLink } from './album.ts'
|
import { parseAlbumLink } from './album.ts'
|
||||||
import { parsePlaylistLink } from './playlist.ts'
|
import { parsePlaylistLink } from './playlist.ts'
|
||||||
@@ -26,7 +26,7 @@ export const parseSong = async(link: string) => {
|
|||||||
}
|
}
|
||||||
delete song.lyricUrl
|
delete song.lyricUrl
|
||||||
delete song.raw
|
delete song.raw
|
||||||
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'qian' as const }
|
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'bd' as const }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||||
@@ -19,14 +19,14 @@ const mapPlaylistSummary = (item: any) => {
|
|||||||
creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName),
|
creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName),
|
||||||
description: rawDesc || tagList.join('、'),
|
description: rawDesc || tagList.join('、'),
|
||||||
link: id ? playlistLink(id) : '',
|
link: id ? playlistLink(id) : '',
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
|
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
|
||||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
|
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
|
||||||
const word = String(keyword ?? '').trim()
|
const word = String(keyword ?? '').trim()
|
||||||
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
|
const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
|
||||||
if (!word) return empty
|
if (!word) return empty
|
||||||
|
|
||||||
const body = await apiGet('/v1/search', {
|
const body = await apiGet('/v1/search', {
|
||||||
@@ -40,7 +40,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
|
|||||||
|
|
||||||
const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : []
|
const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : []
|
||||||
const list = items.map(mapPlaylistSummary).filter((item: any) => item.id)
|
const list = items.map(mapPlaylistSummary).filter((item: any) => item.id)
|
||||||
return { source: 'qian' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
|
return { source: 'bd' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */
|
/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */
|
||||||
@@ -69,7 +69,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
|||||||
const total = Number(data?.trackCount) || list.length
|
const total = Number(data?.trackCount) || list.length
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) },
|
info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) },
|
||||||
list,
|
list,
|
||||||
page,
|
page,
|
||||||
@@ -105,7 +105,7 @@ export const getCategories = async() => {
|
|||||||
list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 })
|
list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { source: 'qian' as const, list }
|
return { source: 'bd' as const, list }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */
|
/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */
|
||||||
@@ -125,7 +125,7 @@ export const getCategoryPlaylists = async(categoryId: string | number, page = 1,
|
|||||||
.map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId }))
|
.map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId }))
|
||||||
.filter((item: any) => item.id)
|
.filter((item: any) => item.id)
|
||||||
return {
|
return {
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
list,
|
list,
|
||||||
total: Number(body?.data?.total) || list.length,
|
total: Number(body?.data?.total) || list.length,
|
||||||
page: pageNo,
|
page: pageNo,
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign:
|
// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign:
|
||||||
// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
||||||
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
|
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
|
||||||
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选,未登录留空)。
|
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选,未登录留空)。
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
export const APP_ID = '16073360'
|
export const APP_ID = '16073360'
|
||||||
@@ -18,7 +18,7 @@ export const RATE_ORDER = ['3000', '320', '128', '64']
|
|||||||
|
|
||||||
/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */
|
/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */
|
||||||
export const getQianCookie = (): string => {
|
export const getQianCookie = (): string => {
|
||||||
const cred = getCredentials('qian') as { cookie?: string } | null
|
const cred = getCredentials('bd') as { cookie?: string } | null
|
||||||
return cred?.cookie ? String(cred.cookie) : ''
|
return cred?.cookie ? String(cred.cookie) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ export const mapTrack = (item: any, opts: MapTrackOptions = {}) => {
|
|||||||
lrcx: '',
|
lrcx: '',
|
||||||
types,
|
types,
|
||||||
_types,
|
_types,
|
||||||
source: 'qian' as const,
|
source: 'bd' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+3
-3
@@ -51,12 +51,12 @@ export const parseSong = async(link: string) => {
|
|||||||
page,
|
page,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 上游 Parse 会立即填充音频地址(失败忽略)
|
// 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
|
||||||
if (cid) {
|
if (cid) {
|
||||||
try {
|
try {
|
||||||
const audio = await getAudioUrl(song.bvid, cid)
|
const audio = await getAudioUrl(song.bvid, cid)
|
||||||
if (audio?.url) {
|
if (audio?.urls?.[0]) {
|
||||||
song.url = audio.url
|
song.url = audio.urls[0]
|
||||||
song.type = audio.type
|
song.type = audio.type
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+14
-14
@@ -157,24 +157,28 @@ export const makeSong = ({ bvid, cid, name, artist, album, duration, cover, page
|
|||||||
|
|
||||||
// ---- 视频/分P/合集接口(上游 bilibili.go 各 fetch* 的对应实现) ----
|
// ---- 视频/分P/合集接口(上游 bilibili.go 各 fetch* 的对应实现) ----
|
||||||
|
|
||||||
|
/** B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误 */
|
||||||
|
const assertApiOk = (what: string, statusCode: number, body: any): void => {
|
||||||
|
if (statusCode !== 200 || !body || body.code !== 0) {
|
||||||
|
throw new Error(`B站${what}获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点 */
|
/** 视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点 */
|
||||||
export const fetchView = async(id: string): Promise<any> => {
|
export const fetchView = async(id: string): Promise<any> => {
|
||||||
const raw = String(id || '').trim()
|
const raw = String(id || '').trim()
|
||||||
if (!raw) throw new Error('B站视频 id 为空')
|
if (!raw) throw new Error('B站视频 id 为空')
|
||||||
const query = /^BV/i.test(raw) ? `bvid=${encodeURIComponent(raw)}` : `aid=${encodeURIComponent(raw.replace(/^av/i, ''))}`
|
const query = /^BV/i.test(raw) ? `bvid=${encodeURIComponent(raw)}` : `aid=${encodeURIComponent(raw.replace(/^av/i, ''))}`
|
||||||
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/web-interface/view?${query}`)
|
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/web-interface/view?${query}`)
|
||||||
if (statusCode !== 200 || !body || body.code !== 0 || !body.data) {
|
assertApiOk('视频信息', statusCode, body)
|
||||||
throw new Error(`B站视频信息获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
|
if (!body.data) throw new Error('B站视频信息获取失败(data 为空)')
|
||||||
}
|
|
||||||
return body.data
|
return body.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分P列表(x/player/pagelist),返回 data 数组 */
|
/** 分P列表(x/player/pagelist),返回 data 数组 */
|
||||||
export const fetchPageList = async(bvid: string): Promise<any[]> => {
|
export const fetchPageList = async(bvid: string): Promise<any[]> => {
|
||||||
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid)}`)
|
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid)}`)
|
||||||
if (statusCode !== 200 || !body || body.code !== 0) {
|
assertApiOk('分P列表', statusCode, body)
|
||||||
throw new Error(`B站分P列表获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
|
|
||||||
}
|
|
||||||
return body.data ?? []
|
return body.data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,10 +192,8 @@ export const fetchSeasonArchiveIndex = async(mid: string | number, seasonId: str
|
|||||||
let pageNum = 1
|
let pageNum = 1
|
||||||
const pageSize = 30
|
const pageSize = 30
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
|
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
|
||||||
if (!body || body.code !== 0) {
|
assertApiOk('合集信息', statusCode, body)
|
||||||
throw new Error(`B站合集信息获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`)
|
|
||||||
}
|
|
||||||
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
|
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
|
||||||
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
|
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
|
||||||
const archives = body.data?.archives ?? []
|
const archives = body.data?.archives ?? []
|
||||||
@@ -276,10 +278,8 @@ export const fetchSeasonSongs = async(mid: string | number, seasonId: string | n
|
|||||||
let pageNum = 1
|
let pageNum = 1
|
||||||
const pageSize = 30
|
const pageSize = 30
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
|
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
|
||||||
if (!body || body.code !== 0) {
|
assertApiOk('合集列表', statusCode, body)
|
||||||
throw new Error(`B站合集列表获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`)
|
|
||||||
}
|
|
||||||
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
|
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
|
||||||
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
|
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
|
||||||
const archives = body.data?.archives ?? []
|
const archives = body.data?.archives ?? []
|
||||||
|
|||||||
+1
-1
@@ -97,5 +97,5 @@ export const decodeName = (str = '') => {
|
|||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
.replace(/ /g, ' ')
|
.replace(/ /g, ' ')
|
||||||
.replace(/&#(\d+);/g, (match, num) => String.fromCharCode(parseInt(num)))
|
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num)))
|
||||||
}
|
}
|
||||||
+8
-12
@@ -7,8 +7,8 @@ import xm from './xm/index.ts'
|
|||||||
import me from './me/index.ts'
|
import me from './me/index.ts'
|
||||||
import qt from './qt/index.ts'
|
import qt from './qt/index.ts'
|
||||||
import lr from './lr/index.ts'
|
import lr from './lr/index.ts'
|
||||||
import migu from './migu/index.ts'
|
import mg from './mg/index.ts'
|
||||||
import qian from './qian/index.ts'
|
import bd from './bd/index.ts'
|
||||||
import joox from './joox/index.ts'
|
import joox from './joox/index.ts'
|
||||||
import bili from './bili/index.ts'
|
import bili from './bili/index.ts'
|
||||||
import fivesing from './5sing/index.ts'
|
import fivesing from './5sing/index.ts'
|
||||||
@@ -22,8 +22,8 @@ import { supportQualitys as xmQualitys } from './apis/xm.ts'
|
|||||||
import { supportQualitys as meQualitys } from './apis/me.ts'
|
import { supportQualitys as meQualitys } from './apis/me.ts'
|
||||||
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
||||||
import { supportQualitys as lrQualitys } from './apis/lr.ts'
|
import { supportQualitys as lrQualitys } from './apis/lr.ts'
|
||||||
import { supportQualitys as miguQualitys } from './apis/migu.ts'
|
import { supportQualitys as mgQualitys } from './apis/mg.ts'
|
||||||
import { supportQualitys as qianQualitys } from './apis/qian.ts'
|
import { supportQualitys as bdQualitys } from './apis/bd.ts'
|
||||||
import { supportQualitys as jooxQualitys } from './apis/joox.ts'
|
import { supportQualitys as jooxQualitys } from './apis/joox.ts'
|
||||||
import { supportQualitys as biliQualitys } from './apis/bili.ts'
|
import { supportQualitys as biliQualitys } from './apis/bili.ts'
|
||||||
import { supportQualitys as fivesingQualitys } from './apis/5sing.ts'
|
import { supportQualitys as fivesingQualitys } from './apis/5sing.ts'
|
||||||
@@ -53,8 +53,8 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
|
|||||||
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
||||||
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
||||||
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
|
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
|
||||||
{ id: 'migu', name: '咪咕音乐', module: migu },
|
{ id: 'mg', name: '咪咕音乐', module: mg },
|
||||||
{ id: 'qian', name: '千千音乐', module: qian },
|
{ id: 'bd', name: '千千音乐', module: bd },
|
||||||
{ id: 'joox', name: 'JOOX', module: joox },
|
{ id: 'joox', name: 'JOOX', module: joox },
|
||||||
{ id: 'bili', name: '哔哩哔哩', module: bili },
|
{ id: 'bili', name: '哔哩哔哩', module: bili },
|
||||||
{ id: '5sing', name: '5sing', module: fivesing },
|
{ id: '5sing', name: '5sing', module: fivesing },
|
||||||
@@ -81,8 +81,8 @@ export const supportQuality: Record<string, string[]> = {
|
|||||||
me: meQualitys,
|
me: meQualitys,
|
||||||
qt: qtQualitys,
|
qt: qtQualitys,
|
||||||
lr: lrQualitys,
|
lr: lrQualitys,
|
||||||
migu: miguQualitys,
|
mg: mgQualitys,
|
||||||
qian: qianQualitys,
|
bd: bdQualitys,
|
||||||
joox: jooxQualitys,
|
joox: jooxQualitys,
|
||||||
bili: biliQualitys,
|
bili: biliQualitys,
|
||||||
'5sing': fivesingQualitys,
|
'5sing': fivesingQualitys,
|
||||||
@@ -176,10 +176,6 @@ export const findMusic = async (
|
|||||||
|
|
||||||
const isEqualsInterval = (intv: number) =>
|
const isEqualsInterval = (intv: number) =>
|
||||||
Math.abs((fInterval || intv) - (intv || fInterval)) < 5
|
Math.abs((fInterval || intv) - (intv || fInterval)) < 5
|
||||||
const isIncludesName = (n: string) => fMusicName.includes(n) || n.includes(fMusicName)
|
|
||||||
const isIncludesSinger = (sng: string) =>
|
|
||||||
fSinger ? fSinger.includes(sng) || sng.includes(fSinger) : true
|
|
||||||
const isEqualsAlbum = (alb: string) => (fAlbumName ? fAlbumName === alb : true)
|
|
||||||
|
|
||||||
let candidates: MatchCandidate[] = []
|
let candidates: MatchCandidate[] = []
|
||||||
for (const source of lists) {
|
for (const source of lists) {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
// 兜底:www.joox.com/<region>/album/<id> SSR __NEXT_DATA__.props.pageProps.albumData(上游做法,需 X-Forwarded-For)
|
// 兜底:www.joox.com/<region>/album/<id> SSR __NEXT_DATA__.props.pageProps.albumData(上游做法,需 X-Forwarded-For)
|
||||||
// 链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
|
// 链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import {
|
import {
|
||||||
jooxHeaders,
|
jooxHeaders,
|
||||||
jooxSign,
|
jooxSign,
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// 解码:base64 → UTF-8 → HTML 实体(& 等)→ { lyric, tlyric: '' }
|
// 解码:base64 → UTF-8 → HTML 实体(& 等)→ { lyric, tlyric: '' }
|
||||||
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
|
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { jooxHeaders, decodeJooxLyric, normalizeJooxID, firstNonEmpty } from './util.ts'
|
import { jooxHeaders, decodeJooxLyric, normalizeJooxID, firstNonEmpty } from './util.ts'
|
||||||
import { buildTrackUrl } from './musicInfo.ts'
|
import { buildTrackUrl } from './musicInfo.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// 返回体含 play_url_list / refrain_url / lrc_content(base64) / qrc_content(base64) /
|
// 返回体含 play_url_list / refrain_url / lrc_content(base64) / qrc_content(base64) /
|
||||||
// has_hq / has_hifi / vip_flag / node_is_preview 等。
|
// has_hq / has_hifi / vip_flag / node_is_preview 等。
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import {
|
import {
|
||||||
jooxHeaders,
|
jooxHeaders,
|
||||||
jooxSign,
|
jooxSign,
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
// 该接口无分页参数(上游也未分页),page 参数仅为统一契约保留;allPage 恒为 1。
|
// 该接口无分页参数(上游也未分页),page 参数仅为统一契约保留;allPage 恒为 1。
|
||||||
// 实测(2026-10-02):英文/中文关键词均 HTTP 200。
|
// 实测(2026-10-02):英文/中文关键词均 HTTP 200。
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { jooxHeaders } from './util.ts'
|
import { jooxHeaders } from './util.ts'
|
||||||
import { jooxSongFromSearch } from './song.ts'
|
import { jooxSongFromSearch } from './song.ts'
|
||||||
|
|
||||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||||
|
|
||||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
export const search = async(keyword: string, _page = 1, limit = 20) => {
|
||||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||||
if (statusCode != 200 || !body?.section_list) {
|
if (statusCode != 200 || !body?.section_list) {
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
import { normalizeJooxID } from './util.ts'
|
import { normalizeJooxID } from './util.ts'
|
||||||
import { getTrackDetail, pickTrackUrl } from './musicInfo.ts'
|
import { getTrackDetail, pickTrackUrl } from './musicInfo.ts'
|
||||||
import { jooxSongFromDetail } from './song.ts'
|
import { jooxSongFromDetail } from './song.ts'
|
||||||
import { getAlbumDetail, parseAlbum } from './album.ts'
|
import { parseAlbum } from './album.ts'
|
||||||
import { getPlaylistDetail, parsePlaylist } from './playlist.ts'
|
import { parsePlaylist } from './playlist.ts'
|
||||||
|
|
||||||
/** 歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则) */
|
/** 歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则) */
|
||||||
export const parseSong = async(link: string) => {
|
export const parseSong = async(link: string) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// 分类:www.joox.com/sg/playlist(302 → /<region> 首页)__NEXT_DATA__.props.pageProps.mlList
|
// 分类:www.joox.com/sg/playlist(302 → /<region> 首页)__NEXT_DATA__.props.pageProps.mlList
|
||||||
// 链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID
|
// 链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import {
|
import {
|
||||||
jooxHeaders,
|
jooxHeaders,
|
||||||
jooxSign,
|
jooxSign,
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
// 在签名参数里作为 uid/usk 参与(可拿到完整曲目/更高档位)。
|
// 在签名参数里作为 uid/usk 参与(可拿到完整曲目/更高档位)。
|
||||||
|
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
export const JOOX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'
|
export const JOOX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { getMusicInfosByList } from './musicInfo.ts'
|
import { getMusicInfosByList } from './musicInfo.ts'
|
||||||
import { createHttpFetch } from './util.ts'
|
import { createHttpFetch } from './util.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240')
|
const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240')
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeName, dateFormat2 } from '../index.ts'
|
import { decodeName, dateFormat2 } from '../index.ts'
|
||||||
import { signatureParams } from './util.ts'
|
import { signatureParams } from './util.ts'
|
||||||
// import { getMusicInfoRaw } from './musicInfo.ts'
|
// import { getMusicInfoRaw } from './musicInfo.ts'
|
||||||
@@ -22,13 +22,11 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
// console.log(body)
|
|
||||||
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取评论失败')
|
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取评论失败')
|
||||||
const total = body.count ?? 0
|
const total = body.count ?? 0
|
||||||
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
||||||
},
|
},
|
||||||
async getHotComment({ hash }, page = 1, limit = 20) {
|
async getHotComment({ hash }, page = 1, limit = 20) {
|
||||||
// console.log(songmid)
|
|
||||||
if (this._requestObj2) this._requestObj2.cancelHttp()
|
if (this._requestObj2) this._requestObj2.cancelHttp()
|
||||||
let timestamp = Date.now()
|
let timestamp = Date.now()
|
||||||
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
|
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
|
||||||
@@ -39,7 +37,6 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj2.promise
|
const { body, statusCode } = await _requestObj2.promise
|
||||||
// console.log(body)
|
|
||||||
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取热门评论失败')
|
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取热门评论失败')
|
||||||
const total = body.count ?? 0
|
const total = body.count ?? 0
|
||||||
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
|
||||||
@@ -57,7 +54,6 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj2.promise
|
const { body, statusCode } = await _requestObj2.promise
|
||||||
// console.log(body)
|
|
||||||
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取回复评论失败')
|
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取回复评论失败')
|
||||||
return { source: 'kg', comments: this.filterComment(body.list || []) }
|
return { source: 'kg', comments: this.filterComment(body.list || []) }
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeName } from '../index.ts'
|
import { decodeName } from '../index.ts'
|
||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ export default {
|
|||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
if (statusCode != 200 || body.errcode !== 0) throw new Error('获取热搜词失败')
|
if (statusCode != 200 || body.errcode !== 0) throw new Error('获取热搜词失败')
|
||||||
// console.log(body, statusCode)
|
|
||||||
return { source: 'kg', list: this.filterList(body.data.list) }
|
return { source: 'kg', list: this.filterList(body.data.list) }
|
||||||
},
|
},
|
||||||
filterList(rawList) {
|
filterList(rawList) {
|
||||||
|
|||||||
+2
-23
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
||||||
import { formatSingerName } from '../utils.ts'
|
import { formatSingerName } from '../utils.ts'
|
||||||
import type { HttpRequestObject, SongType, QualityInfo } from '../types.ts'
|
import type { HttpRequestObject, SongType, QualityInfo } from '../types.ts'
|
||||||
@@ -91,7 +91,6 @@ export default {
|
|||||||
return arr.join('、')
|
return arr.join('、')
|
||||||
},
|
},
|
||||||
filterData(rawList) {
|
filterData(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
return rawList.map(item => {
|
return rawList.map(item => {
|
||||||
const types: Array<SongType & { hash?: string }> = []
|
const types: Array<SongType & { hash?: string }> = []
|
||||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||||
@@ -147,7 +146,6 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
filterBoardsData(rawList) {
|
filterBoardsData(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
let list: Array<{ id: string; name: string; bangid: string }> = []
|
let list: Array<{ id: string; name: string; bangid: string }> = []
|
||||||
for (const board of rawList) {
|
for (const board of rawList) {
|
||||||
if (board.isvol != 1) continue
|
if (board.isvol != 1) continue
|
||||||
@@ -159,24 +157,7 @@ export default {
|
|||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
async getBoards(retryNum = 0) {
|
async getBoards() {
|
||||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
|
||||||
// let response
|
|
||||||
// try {
|
|
||||||
// response = await this.getBoardsData()
|
|
||||||
// } catch (error) {
|
|
||||||
// return this.getBoards(retryNum)
|
|
||||||
// }
|
|
||||||
// // console.log(response.body)
|
|
||||||
// if (response.statusCode !== 200 || response.body.errcode !== 0) return this.getBoards(retryNum)
|
|
||||||
// const list = this.filterBoardsData(response.body.data.info)
|
|
||||||
// console.log(list)
|
|
||||||
// // console.log(JSON.stringify(list))
|
|
||||||
// this.list = list
|
|
||||||
// return {
|
|
||||||
// list,
|
|
||||||
// source: 'kg',
|
|
||||||
// }
|
|
||||||
this.list = boardList
|
this.list = boardList
|
||||||
return {
|
return {
|
||||||
list: boardList,
|
list: boardList,
|
||||||
@@ -189,11 +170,9 @@ export default {
|
|||||||
|
|
||||||
if (body.errcode != 0) return this.getList(bangid, page, retryNum)
|
if (body.errcode != 0) return this.getList(bangid, page, retryNum)
|
||||||
|
|
||||||
// console.log(body)
|
|
||||||
let total = body.data.total
|
let total = body.data.total
|
||||||
let limit = 100
|
let limit = 100
|
||||||
let listData = this.filterData(body.data.info)
|
let listData = this.filterData(body.data.info)
|
||||||
// console.log(listData)
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
list: listData,
|
list: listData,
|
||||||
|
|||||||
+2
-24
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeKrc } from './krcDecode.ts'
|
import { decodeKrc } from './krcDecode.ts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -13,28 +13,6 @@ export default {
|
|||||||
}
|
}
|
||||||
return parseInt(intv as unknown as string)
|
return parseInt(intv as unknown as string)
|
||||||
},
|
},
|
||||||
// getLyric(songInfo, tryNum = 0) {
|
|
||||||
// let requestObj = httpFetch(`http://m.kugou.com/app/i/krc.php?cmd=100&keyword=${encodeURIComponent(songInfo.name)}&hash=${songInfo.hash}&timelength=${songInfo._interval || this.getIntv(songInfo.interval)}&d=0.38664927426725626`, {
|
|
||||||
// headers: {
|
|
||||||
// 'KG-RC': 1,
|
|
||||||
// 'KG-THash': 'expand_search_manager.cpp:852736169:451',
|
|
||||||
// 'User-Agent': 'KuGou2012-9020-ExpandSearchManager',
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// requestObj.promise = requestObj.promise.then(({ body, statusCode }) => {
|
|
||||||
// if (statusCode !== 200) {
|
|
||||||
// if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
|
||||||
// let tryRequestObj = this.getLyric(songInfo, ++tryNum)
|
|
||||||
// requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
|
||||||
// return tryRequestObj.promise
|
|
||||||
// }
|
|
||||||
// return {
|
|
||||||
// lyric: body,
|
|
||||||
// tlyric: '',
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
// },
|
|
||||||
searchLyric(name, hash, time, tryNum = 0) {
|
searchLyric(name, hash, time, tryNum = 0) {
|
||||||
let requestObj = httpFetch(`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`, {
|
let requestObj = httpFetch(`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -92,7 +70,7 @@ export default {
|
|||||||
|
|
||||||
return requestObj
|
return requestObj
|
||||||
},
|
},
|
||||||
getLyric(songInfo, tryNum = 0) {
|
getLyric(songInfo) {
|
||||||
let requestObj = this.searchLyric(songInfo.name, songInfo.hash, songInfo._interval || this.getIntv(songInfo.interval))
|
let requestObj = this.searchLyric(songInfo.name, songInfo.hash, songInfo._interval || this.getIntv(songInfo.interval))
|
||||||
|
|
||||||
requestObj.promise = requestObj.promise.then(result => {
|
requestObj.promise = requestObj.promise.then(result => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const createGetMusicInfosTask = (hashs: Array<{ hash: string }>): Array<Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const filterMusicInfoList = (rawList: any[]): SongInfo[] => {
|
export const filterMusicInfoList = (rawList: any[]): SongInfo[] => {
|
||||||
// console.log(rawList)
|
|
||||||
let ids = new Set()
|
let ids = new Set()
|
||||||
let list: SongInfo[] = []
|
let list: SongInfo[] = []
|
||||||
rawList.forEach(item => {
|
rawList.forEach(item => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
|
||||||
import { formatSingerName } from '../utils.ts'
|
import { formatSingerName } from '../utils.ts'
|
||||||
import type { SongType, QualityInfo } from '../types.ts'
|
import type { SongType, QualityInfo } from '../types.ts'
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
// 上游对应:music-lib/kugou/song.go Parse / kugou.go fetchSongInfo
|
// 上游对应:music-lib/kugou/song.go Parse / kugou.go fetchSongInfo
|
||||||
// 元数据 m.kugou.com/app/i/getSongInfo.php(songName/author_name/timeLength/album_img/bitRate)
|
// 元数据 m.kugou.com/app/i/getSongInfo.php(songName/author_name/timeLength/album_img/bitRate)
|
||||||
// 另尽力合并 musicInfo.getMusicInfo 的专辑/多档 hash,直链取流失败不影响返回(上游为必得 URL)。
|
// 另尽力合并 musicInfo.getMusicInfo 的专辑/多档 hash,直链取流失败不影响返回(上游为必得 URL)。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, decodeName } from '../index.ts'
|
import { formatPlayTime, decodeName } from '../index.ts'
|
||||||
import kgApi from '../apis/kg.ts'
|
import kgApi from '../apis/kg.ts'
|
||||||
import { getMusicInfo } from './musicInfo.ts'
|
import { getMusicInfo } from './musicInfo.ts'
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getPic(songInfo) {
|
getPic(songInfo) {
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
// 链接解析优先复用既有 sdk/kg/songList.ts 的 getUserListDetail / getListDetailBySpecialId /
|
// 链接解析优先复用既有 sdk/kg/songList.ts 的 getUserListDetail / getListDetailBySpecialId /
|
||||||
// getListInfoByChain / decodeGcid 等能力(覆盖 special/single、songlist/gcid、share chain、
|
// getListInfoByChain / decodeGcid 等能力(覆盖 special/single、songlist/gcid、share chain、
|
||||||
// zlist、global_collection_id 等形态),本模块只做统一形状归一。
|
// zlist、global_collection_id 等形态),本模块只做统一形状归一。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import songList from './songList.ts'
|
import songList from './songList.ts'
|
||||||
|
|
||||||
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
|
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
|
||||||
|
|||||||
+2
-145
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
||||||
import { toMD5 } from '../utils.ts'
|
import { toMD5 } from '../utils.ts'
|
||||||
import { signatureParams } from './util.ts'
|
import { signatureParams } from './util.ts'
|
||||||
@@ -66,51 +66,6 @@ export default {
|
|||||||
// https://www.kugou.com/yy/special/single/1067062.html
|
// https://www.kugou.com/yy/special/single/1067062.html
|
||||||
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
|
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
|
||||||
},
|
},
|
||||||
// async getGlobalSpecialId(specialId) {
|
|
||||||
// return httpFetch(`http://mobilecdnbj.kugou.com/api/v5/special/info?specialid=${specialId}`, {
|
|
||||||
// headers: {
|
|
||||||
// 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70',
|
|
||||||
// },
|
|
||||||
// }).promise.then(({ body }) => {
|
|
||||||
// // console.log(body)
|
|
||||||
// if (!body.data.global_specialid) Promise.reject(new Error('Failed to get global collection id.'))
|
|
||||||
// return body.data.global_specialid
|
|
||||||
// })
|
|
||||||
// },
|
|
||||||
// async getListInfoBySpecialId(special_id, retry = 0) {
|
|
||||||
// if (++retry > 2) throw new Error('failed')
|
|
||||||
// return httpFetch(`https://m.kugou.com/plist/list/${special_id}/?json=true`, {
|
|
||||||
// headers: {
|
|
||||||
// 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70',
|
|
||||||
// },
|
|
||||||
// follow_max: 2,
|
|
||||||
// }).promise.then(({ body }) => {
|
|
||||||
// // console.log(body)
|
|
||||||
// if (!body.info.list) return this.getListInfoBySpecialId(special_id, retry)
|
|
||||||
// let listinfo = body.info.list
|
|
||||||
// return {
|
|
||||||
// listInfo: {
|
|
||||||
// name: listinfo.specialname,
|
|
||||||
// image: listinfo.imgurl.replace('{size}', '150'),
|
|
||||||
// intro: listinfo.intro,
|
|
||||||
// author: listinfo.nickname,
|
|
||||||
// playcount: listinfo.playcount,
|
|
||||||
// total: listinfo.songcount,
|
|
||||||
// },
|
|
||||||
// globalSpecialId: listinfo.global_specialid,
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// },
|
|
||||||
// async getSongListDetailByGlobalSpecialId(id, page, limit = 100, retry = 0) {
|
|
||||||
// if (++retry > 2) throw new Error('failed')
|
|
||||||
// console.log(id)
|
|
||||||
// const params = `specialid=0&need_sort=1&module=CloudMusic&clientver=11409&pagesize=${limit}&global_collection_id=${id}&userid=0&page=${page}&type=1&area_code=1&appid=1005`
|
|
||||||
// return httpFetch(`http://pubsongscdn.tx.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params)}`).promise.then(({ body }) => {
|
|
||||||
// // console.log(body)
|
|
||||||
// if (body.data?.info == null) return this.getSongListDetailByGlobalSpecialId(id, page, limit, retry)
|
|
||||||
// return body.data.info
|
|
||||||
// })
|
|
||||||
// },
|
|
||||||
parseHtmlDesc(html) {
|
parseHtmlDesc(html) {
|
||||||
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
|
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
|
||||||
let index = html.indexOf(prefix)
|
let index = html.indexOf(prefix)
|
||||||
@@ -148,30 +103,9 @@ export default {
|
|||||||
name,
|
name,
|
||||||
img: pic,
|
img: pic,
|
||||||
desc,
|
desc,
|
||||||
// author: body.result.info.userinfo.username,
|
|
||||||
// play_count: formatPlayCount(body.result.listen_num),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// const globalSpecialId = await this.getGlobalSpecialId(id)
|
|
||||||
// const limit = 100
|
|
||||||
// const listData = await this.getSongListDetailByGlobalSpecialId(globalSpecialId, page, limit)
|
|
||||||
// if (!Array.isArray(listData))
|
|
||||||
// return this.getUserListDetail2(globalSpecialId)
|
|
||||||
// return {
|
|
||||||
// list: this.filterDatav9(listData),
|
|
||||||
// page,
|
|
||||||
// limit,
|
|
||||||
// total: listInfo.total,
|
|
||||||
// source: 'kg',
|
|
||||||
// info: {
|
|
||||||
// name: listInfo.name,
|
|
||||||
// img: listInfo.image,
|
|
||||||
// desc: listInfo.intro,
|
|
||||||
// author: listInfo.author,
|
|
||||||
// play_count: formatPlayCount(listInfo.playcount),
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
getInfoUrl(tagId?) {
|
getInfoUrl(tagId?) {
|
||||||
return tagId
|
return tagId
|
||||||
@@ -276,10 +210,9 @@ export default {
|
|||||||
try {
|
try {
|
||||||
result = await httpFetch(url, options).promise
|
result = await httpFetch(url, options).promise
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.warn(`[kg] 歌单请求失败将重试(${retryNum + 1}/3) ${url}: ${(err as Error).message}`)
|
||||||
return this.createHttp(url, options, ++retryNum)
|
return this.createHttp(url, options, ++retryNum)
|
||||||
}
|
}
|
||||||
// console.log(result.statusCode, result.body)
|
|
||||||
if (result.statusCode !== 200 ||
|
if (result.statusCode !== 200 ||
|
||||||
(
|
(
|
||||||
(result.body.error_code !== undefined
|
(result.body.error_code !== undefined
|
||||||
@@ -349,7 +282,6 @@ export default {
|
|||||||
},
|
},
|
||||||
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id },
|
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id },
|
||||||
})
|
})
|
||||||
// console.log(songInfo)
|
|
||||||
// type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列
|
// type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列
|
||||||
let songList
|
let songList
|
||||||
let info = songInfo.info
|
let info = songInfo.info
|
||||||
@@ -372,7 +304,6 @@ export default {
|
|||||||
},
|
},
|
||||||
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: info.id, type: 3, userid: info.userid, collect_type: 0, page: 1, pagesize: info.count } },
|
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: info.id, type: 3, userid: info.userid, collect_type: 0, page: 1, pagesize: info.count } },
|
||||||
})
|
})
|
||||||
// console.log(songList)
|
|
||||||
}
|
}
|
||||||
let list = await this.getMusicInfos(songList || songInfo.list)
|
let list = await this.getMusicInfos(songList || songInfo.list)
|
||||||
return {
|
return {
|
||||||
@@ -402,7 +333,6 @@ export default {
|
|||||||
else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain))
|
else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain))
|
||||||
}
|
}
|
||||||
let list = await this.getMusicInfos(songInfo.list)
|
let list = await this.getMusicInfos(songInfo.list)
|
||||||
// console.log(info, songInfo)
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -468,7 +398,6 @@ export default {
|
|||||||
}
|
}
|
||||||
let result = await Promise.all(tasks).then(([...datas]) => datas.flat())
|
let result = await Promise.all(tasks).then(([...datas]) => datas.flat())
|
||||||
result = await this.getMusicInfos(result)
|
result = await this.getMusicInfos(result)
|
||||||
// console.log(result)
|
|
||||||
return {
|
return {
|
||||||
list: result,
|
list: result,
|
||||||
page,
|
page,
|
||||||
@@ -519,7 +448,6 @@ export default {
|
|||||||
})
|
})
|
||||||
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
|
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
|
||||||
let list = await this.getMusicInfos(songInfo)
|
let list = await this.getMusicInfos(songInfo)
|
||||||
// console.log(info, songInfo, list)
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -561,7 +489,6 @@ export default {
|
|||||||
if (result) result = JSON.parse(result[1])
|
if (result) result = JSON.parse(result[1])
|
||||||
this.cache.set(chain, result)
|
this.cache.set(chain, result)
|
||||||
result = await this.getMusicInfos(result)
|
result = await this.getMusicInfos(result)
|
||||||
// console.log(info, songInfo)
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -618,9 +545,7 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// console.log(info)
|
|
||||||
let result = await this.getMusicInfos(info.info)
|
let result = await this.getMusicInfos(info.info)
|
||||||
// console.log(info, songInfo)
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -655,10 +580,8 @@ export default {
|
|||||||
})
|
})
|
||||||
const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise
|
const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise
|
||||||
const loc = location as string | undefined
|
const loc = location as string | undefined
|
||||||
// console.log(body, location)
|
|
||||||
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
|
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
|
||||||
if (loc) {
|
if (loc) {
|
||||||
// console.log(location)
|
|
||||||
if (loc.includes('global_collection_id')) return this.getUserListDetail2(loc.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
|
if (loc.includes('global_collection_id')) return this.getUserListDetail2(loc.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
|
||||||
if (loc.includes('gcid_')) {
|
if (loc.includes('gcid_')) {
|
||||||
let gcid = link.match(/gcid_\w+/)?.[0]
|
let gcid = link.match(/gcid_\w+/)?.[0]
|
||||||
@@ -679,7 +602,6 @@ export default {
|
|||||||
return this.getUserListDetail(link, page, ++retryNum)
|
return this.getUserListDetail(link, page, ++retryNum)
|
||||||
} else return this.getUserListDetail3(loc.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page)
|
} else return this.getUserListDetail3(loc.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page)
|
||||||
}
|
}
|
||||||
// console.log('location', location)
|
|
||||||
return this.getUserListDetail(loc, page, ++retryNum)
|
return this.getUserListDetail(loc, page, ++retryNum)
|
||||||
}
|
}
|
||||||
if (typeof body == 'string') {
|
if (typeof body == 'string') {
|
||||||
@@ -713,7 +635,6 @@ export default {
|
|||||||
return this.getListDetailBySpecialId(id, page)
|
return this.getListDetailBySpecialId(id, page)
|
||||||
},
|
},
|
||||||
filterData(rawList) {
|
filterData(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
return rawList.map(item => {
|
return rawList.map(item => {
|
||||||
const types: Array<{ type: string; size: string; hash?: string }> = []
|
const types: Array<{ type: string; size: string; hash?: string }> = []
|
||||||
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
const _types: Record<string, QualityInfo & { hash?: string }> = {}
|
||||||
@@ -766,72 +687,9 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// getSinger(singers) {
|
|
||||||
// let arr = []
|
|
||||||
// singers?.forEach(singer => {
|
|
||||||
// arr.push(singer.name)
|
|
||||||
// })
|
|
||||||
// return arr.join('、')
|
|
||||||
// },
|
|
||||||
// v9 API
|
|
||||||
// filterDatav9(rawList) {
|
|
||||||
// console.log(rawList)
|
|
||||||
// return rawList.map(item => {
|
|
||||||
// const types = []
|
|
||||||
// const _types = {}
|
|
||||||
// item.relate_goods.forEach(qualityObj => {
|
|
||||||
// if (qualityObj.level === 2) {
|
|
||||||
// let size = sizeFormate(qualityObj.size)
|
|
||||||
// types.push({ type: '128k', size, hash: qualityObj.hash })
|
|
||||||
// _types['128k'] = {
|
|
||||||
// size,
|
|
||||||
// hash: qualityObj.hash,
|
|
||||||
// }
|
|
||||||
// } else if (qualityObj.level === 4) {
|
|
||||||
// let size = sizeFormate(qualityObj.size)
|
|
||||||
// types.push({ type: '320k', size, hash: qualityObj.hash })
|
|
||||||
// _types['320k'] = {
|
|
||||||
// size,
|
|
||||||
// hash: qualityObj.hash,
|
|
||||||
// }
|
|
||||||
// } else if (qualityObj.level === 5) {
|
|
||||||
// let size = sizeFormate(qualityObj.size)
|
|
||||||
// types.push({ type: 'flac', size, hash: qualityObj.hash })
|
|
||||||
// _types.flac = {
|
|
||||||
// size,
|
|
||||||
// hash: qualityObj.hash,
|
|
||||||
// }
|
|
||||||
// } else if (qualityObj.level === 6) {
|
|
||||||
// let size = sizeFormate(qualityObj.size)
|
|
||||||
// types.push({ type: 'flac24bit', size, hash: qualityObj.hash })
|
|
||||||
// _types.flac24bit = {
|
|
||||||
// size,
|
|
||||||
// hash: qualityObj.hash,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// const nameInfo = item.name.split(' - ')
|
|
||||||
// return {
|
|
||||||
// singer: this.getSinger(item.singerinfo),
|
|
||||||
// name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()),
|
|
||||||
// albumName: decodeName(item.albuminfo.name),
|
|
||||||
// albumId: item.albuminfo.id,
|
|
||||||
// songmid: item.audio_id,
|
|
||||||
// source: 'kg',
|
|
||||||
// interval: formatPlayTime(item.timelen / 1000),
|
|
||||||
// img: null,
|
|
||||||
// lrc: null,
|
|
||||||
// hash: item.hash,
|
|
||||||
// types,
|
|
||||||
// _types,
|
|
||||||
// typeUrl: {},
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// },
|
|
||||||
|
|
||||||
// hash list filter
|
// hash list filter
|
||||||
filterData2(rawList) {
|
filterData2(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
let ids = new Set()
|
let ids = new Set()
|
||||||
let list: SongInfo[] = []
|
let list: SongInfo[] = []
|
||||||
rawList.forEach(item => {
|
rawList.forEach(item => {
|
||||||
@@ -959,7 +817,6 @@ export default {
|
|||||||
return httpFetch(`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`)
|
return httpFetch(`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`)
|
||||||
.promise.then(({ body }) => {
|
.promise.then(({ body }) => {
|
||||||
if (body.errcode != 0) throw new Error('filed')
|
if (body.errcode != 0) throw new Error('filed')
|
||||||
// console.log(body.data.info)
|
|
||||||
return {
|
return {
|
||||||
list: body.data.info.map(item => {
|
list: body.data.info.map(item => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-4
@@ -1,9 +1,8 @@
|
|||||||
import { toMD5 } from '../utils.ts'
|
import { toMD5 } from '../utils.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import type { HttpOptions } from '../types.ts'
|
import type { HttpOptions } from '../types.ts'
|
||||||
|
|
||||||
// s.content[0].lyricContent.forEach(([str]) => {
|
// s.content[0].lyricContent.forEach(([str]) => {
|
||||||
// console.log(str)
|
|
||||||
// })
|
// })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,10 +31,9 @@ export const createHttpFetch = async(url: string, options?: HttpOptions, retryNu
|
|||||||
try {
|
try {
|
||||||
result = await httpFetch(url, options).promise
|
result = await httpFetch(url, options).promise
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.warn(`[kg] 请求失败将重试(${retryNum + 1}/3) ${url}: ${(err as Error).message}`)
|
||||||
return createHttpFetch(url, options, ++retryNum)
|
return createHttpFetch(url, options, ++retryNum)
|
||||||
}
|
}
|
||||||
// console.log(result.statusCode, result.body)
|
|
||||||
if (result.statusCode !== 200 ||
|
if (result.statusCode !== 200 ||
|
||||||
(
|
(
|
||||||
result.body.error_code ??
|
result.body.error_code ??
|
||||||
|
|||||||
+2
-32
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeName } from '../index.ts'
|
import { decodeName } from '../index.ts'
|
||||||
import { formatSinger, objStr2JSON } from './util.ts'
|
import { formatSinger, objStr2JSON } from './util.ts'
|
||||||
import type { Quality } from '../types.ts'
|
import type { Quality } from '../types.ts'
|
||||||
@@ -17,9 +17,7 @@ export default {
|
|||||||
limit_list: 36,
|
limit_list: 36,
|
||||||
limit_song: 1000,
|
limit_song: 1000,
|
||||||
filterListDetail(rawList, albumName, albumId) {
|
filterListDetail(rawList, albumName, albumId) {
|
||||||
// console.log(rawList)
|
return rawList.map(item => {
|
||||||
// console.log(rawList.length, rawList2.length)
|
|
||||||
return rawList.map((item, inedx) => {
|
|
||||||
let formats = item.formats.split('|')
|
let formats = item.formats.split('|')
|
||||||
let types: Array<{ type: Quality; size: string | null }> = []
|
let types: Array<{ type: Quality; size: string | null }> = []
|
||||||
let _types: Partial<Record<Quality, { size: string | null }>> = {}
|
let _types: Partial<Record<Quality, { size: string | null }>> = {}
|
||||||
@@ -92,7 +90,6 @@ export default {
|
|||||||
return requestObj_listDetail.promise.then(({ statusCode, body }) => {
|
return requestObj_listDetail.promise.then(({ statusCode, body }) => {
|
||||||
if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
|
if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
|
||||||
body = objStr2JSON(body)
|
body = objStr2JSON(body)
|
||||||
// console.log(body)
|
|
||||||
if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum)
|
if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum)
|
||||||
body.name = decodeName(body.name)
|
body.name = decodeName(body.name)
|
||||||
return {
|
return {
|
||||||
@@ -235,31 +232,4 @@ export default {
|
|||||||
}
|
}
|
||||||
throw new Error('无效的酷我专辑链接')
|
throw new Error('无效的酷我专辑链接')
|
||||||
},
|
},
|
||||||
// getAlbumListDetail(id, page, retryNum = 0) {
|
|
||||||
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
|
||||||
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => {
|
|
||||||
// return resp.promise.then(({ statusCode, body }) => {
|
|
||||||
// console.log(body)
|
|
||||||
// return Promise.reject(new Error('failed'))
|
|
||||||
// // if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
|
|
||||||
// // const data = body.data
|
|
||||||
// // console.log(data)
|
|
||||||
// // if (!data.musicList) return this.getAlbumListDetail(id, page, ++retryNum)
|
|
||||||
// // return {
|
|
||||||
// // list: this.filterListDetail(data.musiclist),
|
|
||||||
// // page,
|
|
||||||
// // limit: this.limit_song,
|
|
||||||
// // total: data.total,
|
|
||||||
// // source: 'kw',
|
|
||||||
// // info: {
|
|
||||||
// // name: data.album,
|
|
||||||
// // img: data.pic,
|
|
||||||
// // desc: data.albuminfo,
|
|
||||||
// // author: data.artist,
|
|
||||||
// // play_count: this.formatPlayCount(data.playCnt),
|
|
||||||
// // },
|
|
||||||
// // }
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// },
|
|
||||||
}
|
}
|
||||||
+1
-3
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { dateFormat2 } from '../index.ts'
|
import { dateFormat2 } from '../index.ts'
|
||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ export default {
|
|||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
if (statusCode != 200 || body.code != '200') throw new Error('获取评论失败')
|
if (statusCode != 200 || body.code != '200') throw new Error('获取评论失败')
|
||||||
// console.log(body)
|
|
||||||
|
|
||||||
const total = body.comments_counts
|
const total = body.comments_counts
|
||||||
return {
|
return {
|
||||||
@@ -37,7 +36,6 @@ export default {
|
|||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj2.promise
|
const { body, statusCode } = await _requestObj2.promise
|
||||||
if (statusCode != 200 || body.code != '200') throw new Error('获取热门评论失败')
|
if (statusCode != 200 || body.code != '200') throw new Error('获取热门评论失败')
|
||||||
// console.log(body)
|
|
||||||
|
|
||||||
const total = body.hot_comments_counts
|
const total = body.hot_comments_counts
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -14,7 +14,6 @@ export default {
|
|||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
if (statusCode != 200 || body.status !== 'ok') throw new Error('获取热搜词失败')
|
if (statusCode != 200 || body.status !== 'ok') throw new Error('获取热搜词失败')
|
||||||
// console.log(body, statusCode)
|
|
||||||
return { source: 'kw', list: this.filterList(body.tagvalue) }
|
return { source: 'kw', list: this.filterList(body.tagvalue) }
|
||||||
},
|
},
|
||||||
filterList(rawList) {
|
filterList(rawList) {
|
||||||
|
|||||||
+1
-21
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import musicSearch from './musicSearch.ts'
|
import musicSearch from './musicSearch.ts'
|
||||||
import { formatSinger } from './util.ts'
|
import { formatSinger } from './util.ts'
|
||||||
import leaderboard from './leaderboard.ts'
|
import leaderboard from './leaderboard.ts'
|
||||||
@@ -21,22 +21,6 @@ const kw = {
|
|||||||
album,
|
album,
|
||||||
playlist,
|
playlist,
|
||||||
parse,
|
parse,
|
||||||
// context: null,
|
|
||||||
|
|
||||||
|
|
||||||
// init(context) {
|
|
||||||
// if (this.isInited) return
|
|
||||||
// this.isInited = true
|
|
||||||
// this.context = context
|
|
||||||
|
|
||||||
// // this.musicSearch.search('我又想你了').then(res => {
|
|
||||||
// // console.log(res)
|
|
||||||
// // })
|
|
||||||
|
|
||||||
// // this.getMusicUrl('62355680', '320k').then(url => {
|
|
||||||
// // console.log(url)
|
|
||||||
// // })
|
|
||||||
// },
|
|
||||||
|
|
||||||
musicSearch,
|
musicSearch,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
@@ -49,7 +33,6 @@ const kw = {
|
|||||||
},
|
},
|
||||||
handleMusicInfo(songInfo) {
|
handleMusicInfo(songInfo) {
|
||||||
return this.getMusicInfo(songInfo).then(info => {
|
return this.getMusicInfo(songInfo).then(info => {
|
||||||
// console.log(JSON.stringify(info))
|
|
||||||
songInfo.name = info.name
|
songInfo.name = info.name
|
||||||
songInfo.singer = formatSinger(info.artist)
|
songInfo.singer = formatSinger(info.artist)
|
||||||
songInfo.img = info.pic
|
songInfo.img = info.pic
|
||||||
@@ -99,9 +82,6 @@ const kw = {
|
|||||||
return `http://www.kuwo.cn/play_detail/${songInfo.songmid}`
|
return `http://www.kuwo.cn/play_detail/${songInfo.songmid}`
|
||||||
},
|
},
|
||||||
|
|
||||||
// init() {
|
|
||||||
// return getToken()
|
|
||||||
// },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default kw
|
export default kw
|
||||||
+2
-24
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, decodeName } from '../index.ts'
|
import { formatPlayTime, decodeName } from '../index.ts'
|
||||||
import { formatSinger, wbdCrypto } from './util.ts'
|
import { formatSinger, wbdCrypto } from './util.ts'
|
||||||
import type { HttpRequestObject, Quality, QualityInfo, SongType } from '../types.ts'
|
import type { HttpRequestObject, Quality, QualityInfo, SongType } from '../types.ts'
|
||||||
@@ -167,7 +167,6 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
filterBoardsData(rawList) {
|
filterBoardsData(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
let list: any[] = []
|
let list: any[] = []
|
||||||
for (const board of rawList) {
|
for (const board of rawList) {
|
||||||
if (board.source != '1') continue
|
if (board.source != '1') continue
|
||||||
@@ -179,24 +178,7 @@ export default {
|
|||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
async getBoards(retryNum = 0) {
|
async getBoards() {
|
||||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
|
||||||
// let response
|
|
||||||
// try {
|
|
||||||
// response = await this.getBoardsData()
|
|
||||||
// } catch (error) {
|
|
||||||
// return this.getBoards(retryNum)
|
|
||||||
// }
|
|
||||||
// console.log(response.body)
|
|
||||||
// if (response.statusCode !== 200 || !response.body.child) return this.getBoards(retryNum)
|
|
||||||
// const list = this.filterBoardsData(response.body.child)
|
|
||||||
// // console.log(list)
|
|
||||||
// console.log(JSON.stringify(list))
|
|
||||||
// this.list = list
|
|
||||||
// return {
|
|
||||||
// list,
|
|
||||||
// source: 'kw',
|
|
||||||
// }
|
|
||||||
this.list = boardList
|
this.list = boardList
|
||||||
return {
|
return {
|
||||||
list: boardList,
|
list: boardList,
|
||||||
@@ -213,7 +195,6 @@ export default {
|
|||||||
|
|
||||||
return request.then(({ statusCode, body }) => {
|
return request.then(({ statusCode, body }) => {
|
||||||
const rawData = wbdCrypto.decodeData(body)
|
const rawData = wbdCrypto.decodeData(body)
|
||||||
// console.log(rawData)
|
|
||||||
const data = rawData.data
|
const data = rawData.data
|
||||||
if (statusCode !== 200 || rawData.code != 200 || !data.musiclist) return this.getList(id, page, retryNum)
|
if (statusCode !== 200 || rawData.code != 200 || !data.musiclist) return this.getList(id, page, retryNum)
|
||||||
|
|
||||||
@@ -230,7 +211,4 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// getDetailPageUrl(id) {
|
|
||||||
// return `http://www.kuwo.cn/rankList/${id}`
|
|
||||||
// },
|
|
||||||
}
|
}
|
||||||
+1
-151
@@ -1,71 +1,7 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { decodeLyric, lrcTools } from './util.ts'
|
import { decodeLyric, lrcTools } from './util.ts'
|
||||||
import { decodeName } from '../index.ts'
|
import { decodeName } from '../index.ts'
|
||||||
|
|
||||||
/*
|
|
||||||
export default {
|
|
||||||
formatTime(time) {
|
|
||||||
let m = parseInt(time / 60)
|
|
||||||
let s = (time % 60).toFixed(2)
|
|
||||||
return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
|
|
||||||
},
|
|
||||||
sortLrcArr(arr) {
|
|
||||||
const lrcSet = new Set()
|
|
||||||
let lrc = []
|
|
||||||
let lrcT = []
|
|
||||||
|
|
||||||
for (const item of arr) {
|
|
||||||
if (lrcSet.has(item.time)) {
|
|
||||||
const tItem = lrc.pop()
|
|
||||||
tItem.time = lrc[lrc.length - 1].time
|
|
||||||
lrcT.push(tItem)
|
|
||||||
lrc.push(item)
|
|
||||||
} else {
|
|
||||||
lrc.push(item)
|
|
||||||
lrcSet.add(item.time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lrcT.length && lrc.length > lrcT.length) {
|
|
||||||
const tItem = lrc.pop()
|
|
||||||
tItem.time = lrc[lrc.length - 1].time
|
|
||||||
lrcT.push(tItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
lrc,
|
|
||||||
lrcT,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transformLrc(songinfo, lrclist) {
|
|
||||||
return `[ti:${songinfo.songName}]\n[ar:${songinfo.artist}]\n[al:${songinfo.album}]\n[by:]\n[offset:0]\n${lrclist ? lrclist.map(l => `[${this.formatTime(l.time)}]${l.lineLyric}\n`).join('') : '暂无歌词'}`
|
|
||||||
},
|
|
||||||
getLyric(songId) {
|
|
||||||
const requestObj = httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${songId}`)
|
|
||||||
requestObj.promise = requestObj.promise.then(({ body }) => {
|
|
||||||
// console.log(body)
|
|
||||||
if (!body.data?.lrclist?.length) return Promise.reject(new Error('Get lyric failed'))
|
|
||||||
let lrcInfo
|
|
||||||
try {
|
|
||||||
lrcInfo = this.sortLrcArr(body.data.lrclist)
|
|
||||||
} catch {
|
|
||||||
return Promise.reject(new Error('Get lyric failed'))
|
|
||||||
}
|
|
||||||
// console.log(body.data.lrclist)
|
|
||||||
// console.log(lrcInfo.lrc, lrcInfo.lrcT)
|
|
||||||
// console.log({
|
|
||||||
// lyric: decodeName(this.transformLrc(body.data.songinfo, lrc)),
|
|
||||||
// tlyric: decodeName(this.transformLrc(body.data.songinfo, lrcT)),
|
|
||||||
// })
|
|
||||||
return {
|
|
||||||
lyric: decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrc)),
|
|
||||||
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrcT)) : '',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return requestObj
|
|
||||||
},
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const buf_key = Buffer.from('yeelion')
|
const buf_key = Buffer.from('yeelion')
|
||||||
const buf_key_len = buf_key.length
|
const buf_key_len = buf_key.length
|
||||||
@@ -87,65 +23,11 @@ const buildParams = (id: string, isGetLyricx: boolean): string => {
|
|||||||
return Buffer.from(output).toString('base64')
|
return Buffer.from(output).toString('base64')
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(buildParams('207527604', false))
|
|
||||||
// console.log(buildParams('207527604', true))
|
|
||||||
|
|
||||||
const timeExp = /^\[([\d:.]*)\]{1}/g
|
const timeExp = /^\[([\d:.]*)\]{1}/g
|
||||||
const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/
|
const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/
|
||||||
const lyricxTag = /^<-?\d+,-?\d+>/
|
const lyricxTag = /^<-?\d+,-?\d+>/
|
||||||
export default {
|
export default {
|
||||||
/* sortLrcArr(arr) {
|
|
||||||
const lrcSet = new Set()
|
|
||||||
let lrc = []
|
|
||||||
let lrcT = []
|
|
||||||
let markIndex = []
|
|
||||||
for (const item of arr) {
|
|
||||||
if (lrcSet.has(item.time)) {
|
|
||||||
if (lrc.length < 2) continue
|
|
||||||
const index = lrc.findIndex(l => l.time == item.time)
|
|
||||||
markIndex.push(index)
|
|
||||||
if (index == lrc.length - 1) {
|
|
||||||
lrcT.push({ ...lrc[index], time: item.time })
|
|
||||||
lrc.push(item)
|
|
||||||
} else {
|
|
||||||
lrcT.push({ ...lrc[index], time: lrc[index + 1].time })
|
|
||||||
if (item.text) {
|
|
||||||
// const lastIndex = lrc.length - 1
|
|
||||||
// markIndex.push(lastIndex)
|
|
||||||
// lrcT.push({ ...lrc[lastIndex], time: lrc[lastIndex - 1].time })
|
|
||||||
lrc.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lrc.push(item)
|
|
||||||
lrcSet.add(item.time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(markIndex)
|
|
||||||
markIndex = Array.from(new Set(markIndex))
|
|
||||||
for (let index = markIndex.length - 1; index >= 0; index--) {
|
|
||||||
lrc.splice(markIndex[index], 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (lrcT.length) {
|
|
||||||
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
|
|
||||||
// const tItem = lrc.pop()
|
|
||||||
// tItem.time = lrc[lrc.length - 1].time
|
|
||||||
// lrcT.push(tItem)
|
|
||||||
// } else {
|
|
||||||
// lrc = arr
|
|
||||||
// lrcT = []
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
console.log(lrc, lrcT)
|
|
||||||
|
|
||||||
return {
|
|
||||||
lrc,
|
|
||||||
lrcT,
|
|
||||||
}
|
|
||||||
}, */
|
|
||||||
sortLrcArr(arr) {
|
sortLrcArr(arr) {
|
||||||
const lrcSet = new Set()
|
const lrcSet = new Set()
|
||||||
let lrc: any[] = []
|
let lrc: any[] = []
|
||||||
@@ -211,48 +93,17 @@ export default {
|
|||||||
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '',
|
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// getLyric2(musicInfo, isGetLyricx = true) {
|
|
||||||
// const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
|
|
||||||
// requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
|
|
||||||
// if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
|
|
||||||
// return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => {
|
|
||||||
// let lrcInfo
|
|
||||||
// console.log(Buffer.from(base64Data, 'base64').toString())
|
|
||||||
// try {
|
|
||||||
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
|
||||||
// } catch {
|
|
||||||
// return Promise.reject(new Error('Get lyric failed'))
|
|
||||||
// }
|
|
||||||
// if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
|
|
||||||
// lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
|
|
||||||
// // console.log(lrcInfo.lyric)
|
|
||||||
// // console.log(lrcInfo.tlyric)
|
|
||||||
// // console.log(lrcInfo.yrc)
|
|
||||||
// // console.log(JSON.stringify(lrcInfo))
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
// },
|
|
||||||
getLyric(musicInfo, isGetLyricx = true) {
|
getLyric(musicInfo, isGetLyricx = true) {
|
||||||
// this.getLyric2(musicInfo)
|
|
||||||
const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
|
const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
|
||||||
requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
|
requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
|
||||||
if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
|
if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
|
||||||
return decodeLyric({ lrcBase64: raw!.toString('base64'), isGetLyricx }).then(base64Data => {
|
return decodeLyric({ lrcBase64: raw!.toString('base64'), isGetLyricx }).then(base64Data => {
|
||||||
// let lrcInfo
|
|
||||||
// try {
|
|
||||||
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
|
||||||
// } catch {
|
|
||||||
// return Promise.reject(new Error('Get lyric failed'))
|
|
||||||
// }
|
|
||||||
let lrcInfo
|
let lrcInfo
|
||||||
// console.log(Buffer.from(base64Data, 'base64').toString())
|
|
||||||
try {
|
try {
|
||||||
lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return Promise.reject(new Error('Get lyric failed'))
|
return Promise.reject(new Error('Get lyric failed'))
|
||||||
}
|
}
|
||||||
// console.log(lrcInfo)
|
|
||||||
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
|
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
|
||||||
try {
|
try {
|
||||||
lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
|
lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
|
||||||
@@ -261,7 +112,6 @@ export default {
|
|||||||
}
|
}
|
||||||
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
|
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
|
||||||
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
|
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
|
||||||
// console.log(lrcInfo)
|
|
||||||
return lrcInfo
|
return lrcInfo
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-11
@@ -1,6 +1,6 @@
|
|||||||
// import '../../polyfill/array.find.js'
|
// import '../../polyfill/array.find.js'
|
||||||
|
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, decodeName } from '../index.ts'
|
import { formatPlayTime, decodeName } from '../index.ts'
|
||||||
// import { debug } from '../../utils/env.ts'
|
// import { debug } from '../../utils/env.ts'
|
||||||
import { formatSinger } from './util.ts'
|
import { formatSinger } from './util.ts'
|
||||||
@@ -14,21 +14,13 @@ export default {
|
|||||||
total: 0,
|
total: 0,
|
||||||
page: 0,
|
page: 0,
|
||||||
allPage: 1,
|
allPage: 1,
|
||||||
// cancelFn: null,
|
|
||||||
musicSearch(str: string, page: number, limit: number) {
|
musicSearch(str: string, page: number, limit: number) {
|
||||||
const musicSearchRequestObj = httpFetch(`http://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(str)}&pn=${page - 1}&rn=${limit}&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1`)
|
const musicSearchRequestObj = httpFetch(`http://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(str)}&pn=${page - 1}&rn=${limit}&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1`)
|
||||||
return musicSearchRequestObj.promise
|
return musicSearchRequestObj.promise
|
||||||
},
|
},
|
||||||
// getImg(songId) {
|
|
||||||
// return httpGet(`http://player.kuwo.cn/webmusic/sj/dtflagdate?flag=6&rid=MUSIC_${songId}`)
|
|
||||||
// },
|
|
||||||
// getLrc(songId) {
|
|
||||||
// return httpGet(`http://mobile.kuwo.cn/mpage/html5/songinfoandlrc?mid=${songId}&flag=0`)
|
|
||||||
// },
|
|
||||||
handleResult(rawData: any) {
|
handleResult(rawData: any) {
|
||||||
const result: SongInfo[] = []
|
const result: SongInfo[] = []
|
||||||
if (!rawData) return result
|
if (!rawData) return result
|
||||||
// console.log(rawData)
|
|
||||||
for (let i = 0; i < rawData.length; i++) {
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
const info = rawData[i]
|
const info = rawData[i]
|
||||||
let songId = info.MUSICRID.replace('MUSIC_', '')
|
let songId = info.MUSICRID.replace('MUSIC_', '')
|
||||||
@@ -97,7 +89,6 @@ export default {
|
|||||||
typeUrl: {},
|
typeUrl: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// console.log(result)
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
search(str: string, page = 1, limit?: number, retryNum = 0) {
|
search(str: string, page = 1, limit?: number, retryNum = 0) {
|
||||||
@@ -105,7 +96,6 @@ export default {
|
|||||||
if (limit == null) limit = this.limit
|
if (limit == null) limit = this.limit
|
||||||
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
|
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
|
||||||
return this.musicSearch(str, page, limit).then(({ body: result }) => {
|
return this.musicSearch(str, page, limit).then(({ body: result }) => {
|
||||||
// console.log(result)
|
|
||||||
if (!result || (result.TOTAL !== '0' && result.SHOW === '0')) return this.search(str, page, limit, ++retryNum)
|
if (!result || (result.TOTAL !== '0' && result.SHOW === '0')) return this.search(str, page, limit, ++retryNum)
|
||||||
let list = this.handleResult(result.abslist)
|
let list = this.handleResult(result.abslist)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 歌曲:https://www.kuwo.cn/play_detail/<rid>(含 ?/# 尾串)、m.kuwo.cn 的 musicId=<rid>、裸 rid
|
// 歌曲:https://www.kuwo.cn/play_detail/<rid>(含 ?/# 尾串)、m.kuwo.cn 的 musicId=<rid>、裸 rid
|
||||||
// 上游对应:music-lib/kuwo/song.go Parse / kuwo.go fetchFullSongInfo(元数据 m.kuwo.cn + 尽力附直链)
|
// 上游对应:music-lib/kuwo/song.go Parse / kuwo.go fetchFullSongInfo(元数据 m.kuwo.cn + 尽力附直链)
|
||||||
// 专辑 / 歌单分别复用 album.parseAlbumLink、playlist.parsePlaylistLink。
|
// 专辑 / 歌单分别复用 album.parseAlbumLink、playlist.parsePlaylistLink。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime } from '../index.ts'
|
import { formatPlayTime } from '../index.ts'
|
||||||
import kwApi from '../apis/kw.ts'
|
import kwApi from '../apis/kw.ts'
|
||||||
import album from './album.ts'
|
import album from './album.ts'
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import type { SongInfo } from '../types.ts'
|
import type { SongInfo } from '../types.ts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
+1
-9
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, decodeName } from '../index.ts'
|
import { formatPlayTime, decodeName } from '../index.ts'
|
||||||
import { formatSinger, objStr2JSON } from './util.ts'
|
import { formatSinger, objStr2JSON } from './util.ts'
|
||||||
import album from './album.ts'
|
import album from './album.ts'
|
||||||
@@ -147,7 +147,6 @@ export default {
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
filterList2(rawData) {
|
filterList2(rawData) {
|
||||||
// console.log(rawData)
|
|
||||||
const list: any[] = []
|
const list: any[] = []
|
||||||
rawData.forEach(item => {
|
rawData.forEach(item => {
|
||||||
if (!item.label) return
|
if (!item.label) return
|
||||||
@@ -196,7 +195,6 @@ export default {
|
|||||||
// 本方法须返回 detailId(调用方据此再查曲目),失败时应重试自身;
|
// 本方法须返回 detailId(调用方据此再查曲目),失败时应重试自身;
|
||||||
// 原先误调 getListDetail(返回歌单详情对象、且把 tryNum 当 page)导致返回类型错位
|
// 原先误调 getListDetail(返回歌单详情对象、且把 tryNum 当 page)导致返回类型错位
|
||||||
if (statusCode != 200 || !body.child) return this.getListDetailDigest5Info(id, ++tryNum)
|
if (statusCode != 200 || !body.child) return this.getListDetailDigest5Info(id, ++tryNum)
|
||||||
// console.log(body)
|
|
||||||
return body.child.length ? body.child[0].sourceid : null
|
return body.child.length ? body.child[0].sourceid : null
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -204,7 +202,6 @@ export default {
|
|||||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||||
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
||||||
return requestObj.promise.then(({ body }) => {
|
return requestObj.promise.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
|
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
|
||||||
return {
|
return {
|
||||||
list: this.filterListDetail(body.musiclist),
|
list: this.filterListDetail(body.musiclist),
|
||||||
@@ -314,7 +311,6 @@ export default {
|
|||||||
|
|
||||||
if (infoData.code != 200) return null
|
if (infoData.code != 200) return null
|
||||||
|
|
||||||
// console.log(infoData)
|
|
||||||
return {
|
return {
|
||||||
name: infoData.data.userInfo.nickname + '喜欢的音乐',
|
name: infoData.data.userInfo.nickname + '喜欢的音乐',
|
||||||
img: infoData.data.userInfo.headImg,
|
img: infoData.data.userInfo.headImg,
|
||||||
@@ -367,13 +363,11 @@ export default {
|
|||||||
author: '',
|
author: '',
|
||||||
play_count: '',
|
play_count: '',
|
||||||
}
|
}
|
||||||
// console.log(listData)
|
|
||||||
return listData
|
return listData
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取歌曲列表内的音乐
|
// 获取歌曲列表内的音乐
|
||||||
getListDetail(id, page, retryNum = 0) {
|
getListDetail(id, page, retryNum = 0) {
|
||||||
// console.log(id)
|
|
||||||
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=5&ownerId=000
|
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=5&ownerId=000
|
||||||
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=4&ownerId=
|
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=4&ownerId=
|
||||||
if (/\/bodian\//.test(id)) return this.getListDetailMusicListByBD(id, page)
|
if (/\/bodian\//.test(id)) return this.getListDetailMusicListByBD(id, page)
|
||||||
@@ -393,7 +387,6 @@ export default {
|
|||||||
return this.getListDetailDigest8(id, page, retryNum)
|
return this.getListDetailDigest8(id, page, retryNum)
|
||||||
},
|
},
|
||||||
filterListDetail(rawData) {
|
filterListDetail(rawData) {
|
||||||
// console.log(rawData)
|
|
||||||
return rawData.map(item => {
|
return rawData.map(item => {
|
||||||
let infoArr = item.N_MINFO.split(';')
|
let infoArr = item.N_MINFO.split(';')
|
||||||
let types: SongType[] = []
|
let types: SongType[] = []
|
||||||
@@ -464,7 +457,6 @@ export default {
|
|||||||
return httpFetch(`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`)
|
return httpFetch(`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`)
|
||||||
.promise.then(({ body }) => {
|
.promise.then(({ body }) => {
|
||||||
body = objStr2JSON(body)
|
body = objStr2JSON(body)
|
||||||
// console.log(body)
|
|
||||||
return {
|
return {
|
||||||
list: body.abslist.map(item => {
|
list: body.abslist.map(item => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
// import { httpGet, httpFetch } from '../request.ts'
|
|
||||||
import { handleKwDecodeLyric } from './lyricDecode.ts'
|
import { handleKwDecodeLyric } from './lyricDecode.ts'
|
||||||
import { createCipheriv, createDecipheriv } from 'crypto'
|
import { createCipheriv, createDecipheriv } from 'crypto'
|
||||||
import { toMD5 } from '../utils.ts'
|
import { toMD5 } from '../utils.ts'
|
||||||
|
|
||||||
// const kw_token = {
|
|
||||||
// token: null,
|
|
||||||
// isGetingToken: false,
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const translationMap = {
|
|
||||||
// "{'": '{"',
|
|
||||||
// "'}\n": '"}',
|
|
||||||
// "'}": '"}',
|
|
||||||
// "':'": '":"',
|
|
||||||
// "','": '","',
|
|
||||||
// "':{'": '":{"',
|
|
||||||
// "':['": '":["',
|
|
||||||
// "'}],'": '"}],"',
|
|
||||||
// "':[{'": '":[{"',
|
|
||||||
// "'},'": '"},"',
|
|
||||||
// "'},{'": '"},{"',
|
|
||||||
// "':[],'": '":[],"',
|
|
||||||
// "':{},'": '":{},"',
|
|
||||||
// "'}]}": '"}]}',
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export const objStr2JSON = str => {
|
|
||||||
// return JSON.parse(str.replace(/(^{'|'}\n$|'}$|':'|','|':\[{'|'}\],'|':{'|'},'|'},{'|':\['|':\[\],'|':{},'|'}]})/g, s => translationMap[s]))
|
|
||||||
// }
|
|
||||||
|
|
||||||
export const objStr2JSON = (str: string): any => {
|
export const objStr2JSON = (str: string): any => {
|
||||||
return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"'))
|
return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"'))
|
||||||
@@ -44,46 +18,9 @@ export const matchToken = (headers: Record<string, any>): string | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// const wait = time => new Promise(resolve => setTimeout(() => resolve(), time))
|
|
||||||
|
|
||||||
|
|
||||||
// export const getToken = (retryNum = 0) => new Promise((resolve, reject) => {
|
|
||||||
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
|
||||||
|
|
||||||
// if (kw_token.isGetingToken) return wait(1000).then(() => getToken(retryNum).then(token => resolve(token)))
|
|
||||||
// if (kw_token.token) return resolve(kw_token.token)
|
|
||||||
// kw_token.isGetingToken = true
|
|
||||||
// httpGet('http://www.kuwo.cn/', (err, resp) => {
|
|
||||||
// kw_token.isGetingToken = false
|
|
||||||
// if (err) return getToken(++retryNum)
|
|
||||||
// if (resp.statusCode != 200) return reject(new Error('获取失败'))
|
|
||||||
// const token = kw_token.token = matchToken(resp.headers)
|
|
||||||
// resolve(token)
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
|
|
||||||
export const decodeLyric = handleKwDecodeLyric
|
export const decodeLyric = handleKwDecodeLyric
|
||||||
|
|
||||||
// export const tokenRequest = async(url, options = {}) => {
|
|
||||||
// let token = kw_token.token
|
|
||||||
// if (!token) token = await getToken()
|
|
||||||
// if (!options.headers) {
|
|
||||||
// options.headers = {
|
|
||||||
// Referer: 'http://www.kuwo.cn/',
|
|
||||||
// csrf: token,
|
|
||||||
// cookie: 'kw_token=' + token,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// const requestObj = httpFetch(url, options)
|
|
||||||
// requestObj.promise = requestObj.promise.then(resp => {
|
|
||||||
// // console.log(resp)
|
|
||||||
// if (resp.statusCode == 200) {
|
|
||||||
// kw_token.token = matchToken(resp.headers)
|
|
||||||
// }
|
|
||||||
// return resp
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
// }
|
|
||||||
|
|
||||||
interface LrcWordInfo {
|
interface LrcWordInfo {
|
||||||
startTime: number
|
startTime: number
|
||||||
@@ -117,7 +54,6 @@ export const lrcTools = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevWord.newTimeStr = `<${prevWord.startTime},${prevWord.endTime - prevWord.startTime}>`
|
prevWord.newTimeStr = `<${prevWord.startTime},${prevWord.endTime - prevWord.startTime}>`
|
||||||
// console.log(prevWord)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -137,7 +73,6 @@ export const lrcTools = {
|
|||||||
}
|
}
|
||||||
const wordTimes = words.match(this.rxps.wordTimeAll)
|
const wordTimes = words.match(this.rxps.wordTimeAll)
|
||||||
if (!wordTimes) return
|
if (!wordTimes) return
|
||||||
// console.log(wordTimes)
|
|
||||||
let preTimeInfo
|
let preTimeInfo
|
||||||
for (const timeStr of wordTimes) {
|
for (const timeStr of wordTimes) {
|
||||||
const result = this.rxps.wordTime.exec(timeStr)
|
const result = this.rxps.wordTime.exec(timeStr)
|
||||||
@@ -167,7 +102,6 @@ export const lrcTools = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
parse(lrc: string): string {
|
parse(lrc: string): string {
|
||||||
// console.log(lrc)
|
|
||||||
const lines = lrc.split(/\r\n|\r|\n/)
|
const lines = lrc.split(/\r\n|\r|\n/)
|
||||||
const tools = Object.create(this)
|
const tools = Object.create(this)
|
||||||
tools.isOK = true
|
tools.isOK = true
|
||||||
@@ -183,7 +117,6 @@ export const lrcTools = {
|
|||||||
if (!tools.lines.length) return ''
|
if (!tools.lines.length) return ''
|
||||||
let lrcs = tools.lines.join('\n')
|
let lrcs = tools.lines.join('\n')
|
||||||
if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}`
|
if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}`
|
||||||
// console.log(lrcs)
|
|
||||||
return lrcs
|
return lrcs
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
// 端点:/yyting/search/searchBatch.action(q=2150)
|
// 端点:/yyting/search/searchBatch.action(q=2150)
|
||||||
// 注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.ts)
|
// 注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.ts)
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
const DOMAIN = 'http://dapis.mting.info'
|
const DOMAIN = 'http://dapis.mting.info'
|
||||||
const SECRET = 'iJ0DgxmdC83#I&j@iwg'
|
const SECRET = 'iJ0DgxmdC83#I&j@iwg'
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
|
// 参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
|
||||||
// 成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
|
// 成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
|
||||||
// (免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
|
// (免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
import { getCredentials, setCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
|
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
|
||||||
|
|||||||
+1
-3
@@ -5,15 +5,13 @@
|
|||||||
import lrApi from '../apis/lr.ts'
|
import lrApi from '../apis/lr.ts'
|
||||||
import musicSearch from './musicSearch.ts'
|
import musicSearch from './musicSearch.ts'
|
||||||
import { getBookMenu } from './menu.ts'
|
import { getBookMenu } from './menu.ts'
|
||||||
import { getLrCookie } from './auth.ts'
|
|
||||||
|
|
||||||
const lr = {
|
const lr = {
|
||||||
musicSearch,
|
musicSearch,
|
||||||
getMusicUrl(songInfo, type) {
|
getMusicUrl(songInfo, type) {
|
||||||
return lrApi.getMusicUrl(songInfo, type)
|
return lrApi.getMusicUrl(songInfo, type)
|
||||||
},
|
},
|
||||||
getPic(songInfo) {
|
getPic() {
|
||||||
const bookId = String(songInfo.songmid ?? '').split(':')[0]
|
|
||||||
// 书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回空由调用方回退)
|
// 书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回空由调用方回退)
|
||||||
return Promise.resolve(null)
|
return Promise.resolve(null)
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
|
// 接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
|
||||||
// 实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
|
// 实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
|
||||||
// 注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)
|
// 注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
|
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
|
||||||
const MENU_URL = 'https://m.lrts.me/ajax/getBookMenu'
|
const MENU_URL = 'https://m.lrts.me/ajax/getBookMenu'
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize=
|
// 接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize=
|
||||||
// 结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name
|
// 结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name
|
||||||
// 注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法),本接口支持翻页故用之
|
// 注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法),本接口支持翻页故用之
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime } from '../index.ts'
|
import { formatPlayTime } from '../index.ts'
|
||||||
|
|
||||||
const EPISODES_URL = 'https://www.missevan.com/dramaapi/getdramaepisodes'
|
const EPISODES_URL = 'https://www.missevan.com/dramaapi/getdramaepisodes'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// ——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型
|
// ——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型
|
||||||
// 声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[]
|
// 声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[]
|
||||||
// 剧条目 songmid 用 "d:<drama_id>" 前缀(取流时自动取首个免费集,见 apis/me.ts);声音条目为裸 sound_id
|
// 剧条目 songmid 用 "d:<drama_id>" 前缀(取流时自动取首个免费集,见 apis/me.ts);声音条目为裸 sound_id
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime } from '../index.ts'
|
import { formatPlayTime } from '../index.ts'
|
||||||
|
|
||||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
// 该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体);
|
// 该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体);
|
||||||
// 网页版 hls.m3u8 为 Widevine 加密,与此无关。
|
// 网页版 hls.m3u8 为 Widevine 加密,与此无关。
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
const APP_SECRET = 'f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ'
|
const APP_SECRET = 'f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ'
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
|||||||
creator: String(item.singer ?? '').trim(),
|
creator: String(item.singer ?? '').trim(),
|
||||||
description: firstNonEmpty(item.desc, item.publishDate),
|
description: firstNonEmpty(item.desc, item.publishDate),
|
||||||
link: miguAlbumLink(albumID),
|
link: miguAlbumLink(albumID),
|
||||||
source: 'migu' as const,
|
source: 'mg' as const,
|
||||||
}
|
}
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
return { list, source: 'migu' as const, page, limit, total: list.length }
|
return { list, source: 'mg' as const, page, limit, total: list.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs) */
|
/** 专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs) */
|
||||||
@@ -101,7 +101,7 @@ export const getAlbumDetail = async(id: string, page = 1, limit = 50) => {
|
|||||||
|
|
||||||
const start = (page - 1) * limit
|
const start = (page - 1) * limit
|
||||||
return {
|
return {
|
||||||
source: 'migu' as const,
|
source: 'mg' as const,
|
||||||
info,
|
info,
|
||||||
list: songs.slice(start, start + limit),
|
list: songs.slice(start, start + limit),
|
||||||
page,
|
page,
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
// 上游对应:music-lib/migu/(migu.go、song.go、lyric.go、album.go、playlist.go、download.go)
|
// 上游对应:music-lib/migu/(migu.go、song.go、lyric.go、album.go、playlist.go、download.go)
|
||||||
// 匿名面(2026-10-02 实测):搜索、PQ(128k mp3) 取流、歌词、封面、专辑曲目、歌单曲目均可用;
|
// 匿名面(2026-10-02 实测):搜索、PQ(128k mp3) 取流、歌词、封面、专辑曲目、歌单曲目均可用;
|
||||||
// HQ/SQ/ZQ 各档需登录 Cookie(接口返回「登录后免费试听」)。
|
// HQ/SQ/ZQ 各档需登录 Cookie(接口返回「登录后免费试听」)。
|
||||||
import miguApi from '../apis/migu.ts'
|
import mgApi from '../apis/mg.ts'
|
||||||
import musicSearch from './musicSearch.ts'
|
import musicSearch from './musicSearch.ts'
|
||||||
import lyric, { contentIdOf } from './lyric.ts'
|
import lyric, { contentIdOf } from './lyric.ts'
|
||||||
import album from './album.ts'
|
import album from './album.ts'
|
||||||
import playlist from './playlist.ts'
|
import playlist from './playlist.ts'
|
||||||
import { fetchSongDetail, miguSongLink } from './util.ts'
|
import { fetchSongDetail, miguSongLink } from './util.ts'
|
||||||
|
|
||||||
const migu = {
|
const mg = {
|
||||||
musicSearch,
|
musicSearch,
|
||||||
getMusicUrl(songInfo: any, type: string) {
|
getMusicUrl(songInfo: any, type: string) {
|
||||||
return miguApi.getMusicUrl(songInfo, type)
|
return mgApi.getMusicUrl(songInfo, type)
|
||||||
},
|
},
|
||||||
getLyric(songInfo: any) {
|
getLyric(songInfo: any) {
|
||||||
return lyric.getLyric(songInfo)
|
return lyric.getLyric(songInfo)
|
||||||
@@ -60,4 +60,4 @@ export const parseSong = async(link: string) => {
|
|||||||
return song
|
return song
|
||||||
}
|
}
|
||||||
|
|
||||||
export default migu
|
export default mg
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// 咪咕歌词 —— resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本
|
// 咪咕歌词 —— resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本
|
||||||
// 上游对应:music-lib/migu/lyric.go
|
// 上游对应:music-lib/migu/lyric.go
|
||||||
import { buildUrl, fetchSongDetail, miguGet, UserAgent } from './util.ts'
|
import { buildUrl, fetchSongDetail, miguGet } from './util.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
const LRC_REFERER = 'https://y.migu.cn/'
|
const LRC_REFERER = 'https://y.migu.cn/'
|
||||||
const LRC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
|
const LRC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
|
||||||
@@ -37,7 +37,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
|||||||
allPage: Math.max(1, Math.ceil(total / limit)),
|
allPage: Math.max(1, Math.ceil(total / limit)),
|
||||||
total,
|
total,
|
||||||
limit,
|
limit,
|
||||||
source: 'migu' as const,
|
source: 'mg' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ const toPlaylist = (item: MiguPlaylistItem, categoryId?: string) => {
|
|||||||
playCount: parseInt(String(item.playNum ?? ''), 10) || 0,
|
playCount: parseInt(String(item.playNum ?? ''), 10) || 0,
|
||||||
creator: firstNonEmpty(item.userName, item.ownerName),
|
creator: firstNonEmpty(item.userName, item.ownerName),
|
||||||
link: miguPlaylistLink(playlistID),
|
link: miguPlaylistLink(playlistID),
|
||||||
source: 'migu' as const,
|
source: 'mg' as const,
|
||||||
...(categoryId ? { categoryId } : {}),
|
...(categoryId ? { categoryId } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
|||||||
const body = await searchAll(keyword, 'songlist', page, limit)
|
const body = await searchAll(keyword, 'songlist', page, limit)
|
||||||
const result: MiguPlaylistItem[] = body?.songListResultData?.result ?? []
|
const result: MiguPlaylistItem[] = body?.songListResultData?.result ?? []
|
||||||
const list = result.map(item => toPlaylist(item)).filter(Boolean)
|
const list = result.map(item => toPlaylist(item)).filter(Boolean)
|
||||||
return { list, source: 'migu' as const, page, limit, total: list.length }
|
return { list, source: 'mg' as const, page, limit, total: list.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 静态分类(上游 miguPlaylistCategoryKeywords) */
|
/** 静态分类(上游 miguPlaylistCategoryKeywords) */
|
||||||
@@ -67,7 +67,7 @@ const CATEGORY_KEYWORDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const getCategories = () => ({
|
export const getCategories = () => ({
|
||||||
source: 'migu' as const,
|
source: 'mg' as const,
|
||||||
list: [
|
list: [
|
||||||
{ id: '', name: '全部', group: '全部' },
|
{ id: '', name: '全部', group: '全部' },
|
||||||
...CATEGORY_KEYWORDS.map(c => ({ id: c.name, name: c.name, group: c.group, hot: Boolean(c.hot) })),
|
...CATEGORY_KEYWORDS.map(c => ({ id: c.name, name: c.name, group: c.group, hot: Boolean(c.hot) })),
|
||||||
@@ -77,7 +77,7 @@ export const getCategories = () => ({
|
|||||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||||
const keyword = String(categoryId ?? '').trim() || '华语'
|
const keyword = String(categoryId ?? '').trim() || '华语'
|
||||||
const { list } = await searchPlaylist(keyword, page, limit)
|
const { list } = await searchPlaylist(keyword, page, limit)
|
||||||
return { list: list.map(p => toPlaylist(p as any, keyword)).filter(Boolean), source: 'migu' as const, page, limit }
|
return { list: list.map(p => toPlaylist(p as any, keyword)).filter(Boolean), source: 'mg' as const, page, limit }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs) */
|
/** 歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs) */
|
||||||
@@ -123,7 +123,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
|||||||
const { songs, total } = await fetchPlaylistSongs(playlistID)
|
const { songs, total } = await fetchPlaylistSongs(playlistID)
|
||||||
const start = (page - 1) * limit
|
const start = (page - 1) * limit
|
||||||
return {
|
return {
|
||||||
source: 'migu' as const,
|
source: 'mg' as const,
|
||||||
info: { name: playlistID, img: songs[0]?.img ?? '', desc: '', author: '' },
|
info: { name: playlistID, img: songs[0]?.img ?? '', desc: '', author: '' },
|
||||||
list: songs.slice(start, start + limit),
|
list: songs.slice(start, start + limit),
|
||||||
page,
|
page,
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// 上游对应:music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等)
|
// 上游对应:music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等)
|
||||||
// 说明:匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie(对话框「登录后免费试听」)。
|
// 说明:匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie(对话框「登录后免费试听」)。
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
import type { SongType } from '../types.ts'
|
import type { SongType } from '../types.ts'
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const ENCRYPTED_APP_CODE = Buffer.from([
|
|||||||
])
|
])
|
||||||
|
|
||||||
export const getMiguCookie = (): string => {
|
export const getMiguCookie = (): string => {
|
||||||
const cred = getCredentials('migu') as { cookie?: string } | null
|
const cred = getCredentials('mg') as { cookie?: string } | null
|
||||||
return String(cred?.cookie ?? '').trim()
|
return String(cred?.cookie ?? '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ export interface MiguSong {
|
|||||||
payPlay?: boolean
|
payPlay?: boolean
|
||||||
/** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */
|
/** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */
|
||||||
extra: Record<string, string>
|
extra: Record<string, string>
|
||||||
source: 'migu'
|
source: 'mg'
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +328,6 @@ export const convertItemToSong = (item: MiguSongItem, allowPaid = true): MiguSon
|
|||||||
const best = candidates[0]!
|
const best = candidates[0]!
|
||||||
const bestFormat = rateFormats[best.index]!
|
const bestFormat = rateFormats[best.index]!
|
||||||
|
|
||||||
const bitrate = duration > 0 && best.size > 0 ? Math.floor(best.size * 8 / 1000 / duration) : 0
|
|
||||||
const coverURL = normalizeMiguImageURL(firstNonEmpty(
|
const coverURL = normalizeMiguImageURL(firstNonEmpty(
|
||||||
pickMiguImage(item.imgItems), pickMiguImage(item.albumImgs), item.img1, item.img2, item.img3,
|
pickMiguImage(item.imgItems), pickMiguImage(item.albumImgs), item.img1, item.img2, item.img3,
|
||||||
))
|
))
|
||||||
@@ -369,7 +368,7 @@ export const convertItemToSong = (item: MiguSongItem, allowPaid = true): MiguSon
|
|||||||
payPlay: isVIP,
|
payPlay: isVIP,
|
||||||
// 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里
|
// 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里
|
||||||
extra: { ...extra, link: miguSongLink(linkID) },
|
extra: { ...extra, link: miguSongLink(linkID) },
|
||||||
source: 'migu',
|
source: 'mg',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
File renamed without changes.
@@ -1,152 +0,0 @@
|
|||||||
import { b64encode, getCrypto, getSubtle, hash, pkcs7Pad } from "./index.ts";
|
|
||||||
import { ProtobufWriter } from "./protobuf.ts";
|
|
||||||
import { simonEncrypt } from "./simon.ts";
|
|
||||||
import { sm3 } from "./sm3.ts";
|
|
||||||
import type { UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
|
|
||||||
const LOW_RAND = new Uint8Array([0xf2, 0x81]);
|
|
||||||
const HIGH_RAND = new Uint8Array([0x61, 0x6f]); // 'a', 'o'
|
|
||||||
const XOR_PREFIX = new Uint8Array([0xf2, 0xf7, 0xfc, 0xff, 0xf2, 0xf7, 0xfc, 0xff]);
|
|
||||||
|
|
||||||
function sm3Prefix6(data: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
||||||
return sm3(data).slice(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeStub(xssStub: string): Uint8Array<ArrayBuffer> {
|
|
||||||
const bytes = new Uint8Array(16);
|
|
||||||
if (xssStub.length >= 32) {
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
bytes[i] = parseInt(xssStub.slice(i * 2, i * 2 + 2), 16) & 0xff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateConstant(code: string): number {
|
|
||||||
const parts = [...code.replace(/\./g, "").padStart(6, "0")].map(Number);
|
|
||||||
const weights = [20480, 2048, 20971520, 2097152, 1342177280, 134217728];
|
|
||||||
return parts.reduce((sum, part, i) => sum + part * weights[i]!, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProtobuf(
|
|
||||||
query: string,
|
|
||||||
xssStub: string,
|
|
||||||
timestamp: number,
|
|
||||||
config: UnidbgConfig,
|
|
||||||
): Uint8Array<ArrayBuffer> {
|
|
||||||
const params = new URLSearchParams(query);
|
|
||||||
const deviceId = params.get("device_id") ?? "";
|
|
||||||
const versionName = params.get("version_name") ?? "";
|
|
||||||
const deviceType = params.get("device_type") ?? "";
|
|
||||||
const osVersion = params.get("os_version") ?? "";
|
|
||||||
const channel = params.get("channel") ?? "";
|
|
||||||
|
|
||||||
// 空 stub / 空 query 时都对 16 字节全零求 SM3
|
|
||||||
const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub));
|
|
||||||
const queryHash = sm3Prefix6(
|
|
||||||
query === "" ? new Uint8Array(16) : new TextEncoder().encode(query),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rand = getCrypto().getRandomValues(new Uint32Array(1))[0]! % 0x7fffffff;
|
|
||||||
|
|
||||||
return new ProtobufWriter()
|
|
||||||
.varint(1, 0x20200929 * 2) // magic
|
|
||||||
.varint(2, 2) // version
|
|
||||||
.varint(3, rand)
|
|
||||||
.string(4, config.aid)
|
|
||||||
.string(5, deviceId)
|
|
||||||
.string(6, config.licenseId)
|
|
||||||
.string(7, versionName)
|
|
||||||
.string(8, config.sdkVersion)
|
|
||||||
.varint(9, config.sdkVersionInt)
|
|
||||||
.bytes(10, new Uint8Array(8)) // envcode
|
|
||||||
.varint(11, 0) // platform
|
|
||||||
.varint(12, timestamp * 2) // createTime
|
|
||||||
.bytes(13, bodyHash)
|
|
||||||
.bytes(14, queryHash)
|
|
||||||
.message(15, (sub) => {
|
|
||||||
sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860);
|
|
||||||
})
|
|
||||||
.string(16, config.secDeviceId ?? "") // sec_device_id
|
|
||||||
.string(20, "none") // pskVersion
|
|
||||||
.varint(21, config.callType)
|
|
||||||
.message(23, (sub) => {
|
|
||||||
sub.string(1, deviceType).string(2, osVersion).string(3, channel).varint(4, calculateConstant(osVersion));
|
|
||||||
})
|
|
||||||
.varint(25, 2)
|
|
||||||
.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function getArgus(
|
|
||||||
query: string,
|
|
||||||
xssStub: string,
|
|
||||||
timestamp: number,
|
|
||||||
config: UnidbgConfig,
|
|
||||||
): Promise<string> {
|
|
||||||
const { signKey } = config;
|
|
||||||
if (signKey.length !== 32) {
|
|
||||||
throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16);
|
|
||||||
|
|
||||||
const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length);
|
|
||||||
sm3Input.set(signKey, 0);
|
|
||||||
sm3Input.set(LOW_RAND, signKey.length);
|
|
||||||
sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length);
|
|
||||||
sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length);
|
|
||||||
|
|
||||||
const sm3Output = sm3(sm3Input);
|
|
||||||
const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength);
|
|
||||||
const simonKey: [bigint, bigint, bigint, bigint] = [
|
|
||||||
keyView.getBigUint64(0, true),
|
|
||||||
keyView.getBigUint64(8, true),
|
|
||||||
keyView.getBigUint64(16, true),
|
|
||||||
keyView.getBigUint64(24, true),
|
|
||||||
];
|
|
||||||
|
|
||||||
const encrypted = new Uint8Array(protobuf.length);
|
|
||||||
const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength);
|
|
||||||
const encView = new DataView(encrypted.buffer);
|
|
||||||
for (let offset = 0; offset < protobuf.length; offset += 16) {
|
|
||||||
const [low, high] = simonEncrypt(
|
|
||||||
[pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)],
|
|
||||||
simonKey,
|
|
||||||
);
|
|
||||||
encView.setBigUint64(offset, low, true);
|
|
||||||
encView.setBigUint64(offset + 8, high, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new Uint8Array(XOR_PREFIX.length + encrypted.length);
|
|
||||||
data.set(XOR_PREFIX);
|
|
||||||
data.set(encrypted, XOR_PREFIX.length);
|
|
||||||
for (let i = XOR_PREFIX.length; i < data.length; i++) {
|
|
||||||
data[i]! ^= data[i % 8]!;
|
|
||||||
}
|
|
||||||
data.reverse();
|
|
||||||
|
|
||||||
const header = new Uint8Array([0xa6, 0x6e, 0xad, 0x9f, 0x77, 0x01, 0xd0, 0x0c, 0x18]);
|
|
||||||
const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length);
|
|
||||||
plaintext.set(header);
|
|
||||||
plaintext.set(data, header.length);
|
|
||||||
plaintext.set(HIGH_RAND, header.length + data.length);
|
|
||||||
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const aesKey = await subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
await hash.md5bytes(signKey.slice(0, 16)),
|
|
||||||
{ name: "AES-CBC" },
|
|
||||||
false,
|
|
||||||
["encrypt"],
|
|
||||||
);
|
|
||||||
const iv = await hash.md5bytes(signKey.slice(16));
|
|
||||||
const ciphertext = new Uint8Array(
|
|
||||||
await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = new Uint8Array(LOW_RAND.length + ciphertext.length);
|
|
||||||
result.set(LOW_RAND);
|
|
||||||
result.set(ciphertext, LOW_RAND.length);
|
|
||||||
return b64encode(result.buffer);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import md5 from "./md5.ts";
|
|
||||||
import { sm3 } from "./sm3.ts";
|
|
||||||
|
|
||||||
export function getCrypto(): Crypto {
|
|
||||||
const c = globalThis.crypto as Crypto | undefined;
|
|
||||||
if (!c?.subtle) {
|
|
||||||
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSubtle(): SubtleCrypto {
|
|
||||||
return getCrypto().subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function b64decode(b64: string): ArrayBuffer {
|
|
||||||
const binaryString = atob(b64);
|
|
||||||
const len = binaryString.length;
|
|
||||||
const bytes = new Uint8Array(len);
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function b64encode(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const chunkSize = 0x8000;
|
|
||||||
const chunks: string[] = [];
|
|
||||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
||||||
chunks.push(
|
|
||||||
String.fromCharCode(...bytes.subarray(i, i + chunkSize)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return btoa(chunks.join(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unhex(hex: string): ArrayBuffer {
|
|
||||||
if (hex.length % 2 !== 0) {
|
|
||||||
throw new Error("Invalid hex string");
|
|
||||||
}
|
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
const byte = parseInt(hex.slice(i, i + 2), 16);
|
|
||||||
if (Number.isNaN(byte)) {
|
|
||||||
throw new Error("Invalid hex string");
|
|
||||||
}
|
|
||||||
bytes[i / 2] = byte;
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hex(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let hexString = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
hexString += bytes[i]!.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
return hexString;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
|
|
||||||
const padLength = blockSize - (data.length % blockSize);
|
|
||||||
const padded = new Uint8Array(data.length + padLength);
|
|
||||||
padded.set(data);
|
|
||||||
padded.fill(padLength, data.length);
|
|
||||||
return padded;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function randomString(length: number): string {
|
|
||||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let result = "";
|
|
||||||
const array = new Uint8Array(length);
|
|
||||||
getCrypto().getRandomValues(array);
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += chars.charAt(array[i]! % chars.length);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HashInput = ArrayBuffer | Uint8Array<ArrayBuffer> | string;
|
|
||||||
|
|
||||||
function toBytes(input: HashInput): Uint8Array<ArrayBuffer> {
|
|
||||||
if (typeof input === "string") {
|
|
||||||
return new TextEncoder().encode(input);
|
|
||||||
}
|
|
||||||
if (input instanceof Uint8Array) {
|
|
||||||
return new Uint8Array(input);
|
|
||||||
}
|
|
||||||
return new Uint8Array(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hash = {
|
|
||||||
sha256: async (input: HashInput): Promise<string> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const digest = await subtle.digest("SHA-256", toBytes(input));
|
|
||||||
return hex(digest);
|
|
||||||
},
|
|
||||||
sha256bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
return subtle.digest("SHA-256", toBytes(input));
|
|
||||||
},
|
|
||||||
sha512: async (input: HashInput): Promise<string> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const digest = await subtle.digest("SHA-512", toBytes(input));
|
|
||||||
return hex(digest);
|
|
||||||
},
|
|
||||||
sha512bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
return subtle.digest("SHA-512", toBytes(input));
|
|
||||||
},
|
|
||||||
md5: async (input: HashInput): Promise<string> => md5(
|
|
||||||
typeof input === "string" ? input : toBytes(input).buffer,
|
|
||||||
),
|
|
||||||
md5bytes: async (input: HashInput): Promise<ArrayBuffer> => unhex(md5(
|
|
||||||
typeof input === "string" ? input : toBytes(input).buffer,
|
|
||||||
)),
|
|
||||||
sm3: async (input: HashInput): Promise<string> => hex(sm3(toBytes(input)).buffer),
|
|
||||||
sm3bytes: async (input: HashInput): Promise<ArrayBuffer> => sm3(toBytes(input)).buffer,
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { b64encode, getCrypto } from "./index.ts";
|
|
||||||
import { generateLadonKey, speckEncrypt } from "./speck.ts";
|
|
||||||
import type { UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
|
|
||||||
export async function getLadon(timestamp: number, config: UnidbgConfig): Promise<string> {
|
|
||||||
const randomBytes = getCrypto().getRandomValues(new Uint8Array(4));
|
|
||||||
|
|
||||||
const plaintext = new TextEncoder().encode(
|
|
||||||
`${timestamp}-${config.licenseId}-${config.aid}`,
|
|
||||||
);
|
|
||||||
const key = await generateLadonKey(randomBytes, config.aid);
|
|
||||||
const encrypted = speckEncrypt(key, plaintext);
|
|
||||||
|
|
||||||
const result = new Uint8Array(randomBytes.length + encrypted.length);
|
|
||||||
result.set(randomBytes);
|
|
||||||
result.set(encrypted, randomBytes.length);
|
|
||||||
return b64encode(result.buffer);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
function rotateLeft(value: number, shiftBits: number): number {
|
|
||||||
return (value << shiftBits) | (value >>> (32 - shiftBits));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addUnsigned(left: number, right: number): number {
|
|
||||||
return (left + right) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function f(x: number, y: number, z: number): number {
|
|
||||||
return (x & y) | (~x & z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function g(x: number, y: number, z: number): number {
|
|
||||||
return (x & z) | (y & ~z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function h(x: number, y: number, z: number): number {
|
|
||||||
return x ^ y ^ z;
|
|
||||||
}
|
|
||||||
|
|
||||||
function i(x: number, y: number, z: number): number {
|
|
||||||
return y ^ (x | ~z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ff(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gg(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hh(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ii(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToWordArray(bytes: Uint8Array): number[] {
|
|
||||||
const wordCount = Math.ceil((bytes.length + 9) / 64) * 16;
|
|
||||||
const words = new Array<number>(wordCount).fill(0);
|
|
||||||
|
|
||||||
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
|
|
||||||
const wordIndex = Math.floor(byteIndex / 4);
|
|
||||||
const bytePosition = (byteIndex % 4) * 8;
|
|
||||||
words[wordIndex] = words[wordIndex]! | (bytes[byteIndex]! << bytePosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paddingWordIndex = Math.floor(bytes.length / 4);
|
|
||||||
const paddingBytePosition = (bytes.length % 4) * 8;
|
|
||||||
words[paddingWordIndex] = words[paddingWordIndex]! | (0x80 << paddingBytePosition);
|
|
||||||
words[wordCount - 2] = bytes.length << 3;
|
|
||||||
words[wordCount - 1] = bytes.length >>> 29;
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wordToHex(value: number): string {
|
|
||||||
let hex = "";
|
|
||||||
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
|
|
||||||
const byte = (value >>> (byteIndex * 8)) & 0xff;
|
|
||||||
hex += byte.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算 MD5 摘要。
|
|
||||||
*/
|
|
||||||
export default function md5(input: string | ArrayBuffer): string {
|
|
||||||
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
|
|
||||||
const words = convertToWordArray(bytes);
|
|
||||||
|
|
||||||
let a = 0x67452301;
|
|
||||||
let b = 0xefcdab89;
|
|
||||||
let c = 0x98badcfe;
|
|
||||||
let d = 0x10325476;
|
|
||||||
|
|
||||||
for (let k = 0; k < words.length; k += 16) {
|
|
||||||
const aa = a;
|
|
||||||
const bb = b;
|
|
||||||
const cc = c;
|
|
||||||
const dd = d;
|
|
||||||
|
|
||||||
a = ff(a, b, c, d, words[k]!, 7, 0xd76aa478);
|
|
||||||
d = ff(d, a, b, c, words[k + 1]!, 12, 0xe8c7b756);
|
|
||||||
c = ff(c, d, a, b, words[k + 2]!, 17, 0x242070db);
|
|
||||||
b = ff(b, c, d, a, words[k + 3]!, 22, 0xc1bdceee);
|
|
||||||
a = ff(a, b, c, d, words[k + 4]!, 7, 0xf57c0faf);
|
|
||||||
d = ff(d, a, b, c, words[k + 5]!, 12, 0x4787c62a);
|
|
||||||
c = ff(c, d, a, b, words[k + 6]!, 17, 0xa8304613);
|
|
||||||
b = ff(b, c, d, a, words[k + 7]!, 22, 0xfd469501);
|
|
||||||
a = ff(a, b, c, d, words[k + 8]!, 7, 0x698098d8);
|
|
||||||
d = ff(d, a, b, c, words[k + 9]!, 12, 0x8b44f7af);
|
|
||||||
c = ff(c, d, a, b, words[k + 10]!, 17, 0xffff5bb1);
|
|
||||||
b = ff(b, c, d, a, words[k + 11]!, 22, 0x895cd7be);
|
|
||||||
a = ff(a, b, c, d, words[k + 12]!, 7, 0x6b901122);
|
|
||||||
d = ff(d, a, b, c, words[k + 13]!, 12, 0xfd987193);
|
|
||||||
c = ff(c, d, a, b, words[k + 14]!, 17, 0xa679438e);
|
|
||||||
b = ff(b, c, d, a, words[k + 15]!, 22, 0x49b40821);
|
|
||||||
|
|
||||||
a = gg(a, b, c, d, words[k + 1]!, 5, 0xf61e2562);
|
|
||||||
d = gg(d, a, b, c, words[k + 6]!, 9, 0xc040b340);
|
|
||||||
c = gg(c, d, a, b, words[k + 11]!, 14, 0x265e5a51);
|
|
||||||
b = gg(b, c, d, a, words[k]!, 20, 0xe9b6c7aa);
|
|
||||||
a = gg(a, b, c, d, words[k + 5]!, 5, 0xd62f105d);
|
|
||||||
d = gg(d, a, b, c, words[k + 10]!, 9, 0x02441453);
|
|
||||||
c = gg(c, d, a, b, words[k + 15]!, 14, 0xd8a1e681);
|
|
||||||
b = gg(b, c, d, a, words[k + 4]!, 20, 0xe7d3fbc8);
|
|
||||||
a = gg(a, b, c, d, words[k + 9]!, 5, 0x21e1cde6);
|
|
||||||
d = gg(d, a, b, c, words[k + 14]!, 9, 0xc33707d6);
|
|
||||||
c = gg(c, d, a, b, words[k + 3]!, 14, 0xf4d50d87);
|
|
||||||
b = gg(b, c, d, a, words[k + 8]!, 20, 0x455a14ed);
|
|
||||||
a = gg(a, b, c, d, words[k + 13]!, 5, 0xa9e3e905);
|
|
||||||
d = gg(d, a, b, c, words[k + 2]!, 9, 0xfcefa3f8);
|
|
||||||
c = gg(c, d, a, b, words[k + 7]!, 14, 0x676f02d9);
|
|
||||||
b = gg(b, c, d, a, words[k + 12]!, 20, 0x8d2a4c8a);
|
|
||||||
|
|
||||||
a = hh(a, b, c, d, words[k + 5]!, 4, 0xfffa3942);
|
|
||||||
d = hh(d, a, b, c, words[k + 8]!, 11, 0x8771f681);
|
|
||||||
c = hh(c, d, a, b, words[k + 11]!, 16, 0x6d9d6122);
|
|
||||||
b = hh(b, c, d, a, words[k + 14]!, 23, 0xfde5380c);
|
|
||||||
a = hh(a, b, c, d, words[k + 1]!, 4, 0xa4beea44);
|
|
||||||
d = hh(d, a, b, c, words[k + 4]!, 11, 0x4bdecfa9);
|
|
||||||
c = hh(c, d, a, b, words[k + 7]!, 16, 0xf6bb4b60);
|
|
||||||
b = hh(b, c, d, a, words[k + 10]!, 23, 0xbebfbc70);
|
|
||||||
a = hh(a, b, c, d, words[k + 13]!, 4, 0x289b7ec6);
|
|
||||||
d = hh(d, a, b, c, words[k]!, 11, 0xeaa127fa);
|
|
||||||
c = hh(c, d, a, b, words[k + 3]!, 16, 0xd4ef3085);
|
|
||||||
b = hh(b, c, d, a, words[k + 6]!, 23, 0x04881d05);
|
|
||||||
a = hh(a, b, c, d, words[k + 9]!, 4, 0xd9d4d039);
|
|
||||||
d = hh(d, a, b, c, words[k + 12]!, 11, 0xe6db99e5);
|
|
||||||
c = hh(c, d, a, b, words[k + 15]!, 16, 0x1fa27cf8);
|
|
||||||
b = hh(b, c, d, a, words[k + 2]!, 23, 0xc4ac5665);
|
|
||||||
|
|
||||||
a = ii(a, b, c, d, words[k]!, 6, 0xf4292244);
|
|
||||||
d = ii(d, a, b, c, words[k + 7]!, 10, 0x432aff97);
|
|
||||||
c = ii(c, d, a, b, words[k + 14]!, 15, 0xab9423a7);
|
|
||||||
b = ii(b, c, d, a, words[k + 5]!, 21, 0xfc93a039);
|
|
||||||
a = ii(a, b, c, d, words[k + 12]!, 6, 0x655b59c3);
|
|
||||||
d = ii(d, a, b, c, words[k + 3]!, 10, 0x8f0ccc92);
|
|
||||||
c = ii(c, d, a, b, words[k + 10]!, 15, 0xffeff47d);
|
|
||||||
b = ii(b, c, d, a, words[k + 1]!, 21, 0x85845dd1);
|
|
||||||
a = ii(a, b, c, d, words[k + 8]!, 6, 0x6fa87e4f);
|
|
||||||
d = ii(d, a, b, c, words[k + 15]!, 10, 0xfe2ce6e0);
|
|
||||||
c = ii(c, d, a, b, words[k + 6]!, 15, 0xa3014314);
|
|
||||||
b = ii(b, c, d, a, words[k + 13]!, 21, 0x4e0811a1);
|
|
||||||
a = ii(a, b, c, d, words[k + 4]!, 6, 0xf7537e82);
|
|
||||||
d = ii(d, a, b, c, words[k + 11]!, 10, 0xbd3af235);
|
|
||||||
c = ii(c, d, a, b, words[k + 2]!, 15, 0x2ad7d2bb);
|
|
||||||
b = ii(b, c, d, a, words[k + 9]!, 21, 0xeb86d391);
|
|
||||||
|
|
||||||
a = addUnsigned(a, aa);
|
|
||||||
b = addUnsigned(b, bb);
|
|
||||||
c = addUnsigned(c, cc);
|
|
||||||
d = addUnsigned(d, dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
const WIRE_VARINT = 0;
|
|
||||||
const WIRE_BYTES = 2;
|
|
||||||
const WIRE_FIXED32 = 5;
|
|
||||||
|
|
||||||
export class ProtobufWriter {
|
|
||||||
private readonly buf: number[] = [];
|
|
||||||
|
|
||||||
private writeVarint(value: number): this {
|
|
||||||
let v = value >>> 0;
|
|
||||||
while (v >= 0x80) {
|
|
||||||
this.buf.push((v & 0x7f) | 0x80);
|
|
||||||
v >>>= 7;
|
|
||||||
}
|
|
||||||
this.buf.push(v & 0x7f);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeKey(fieldNumber: number, wireType: number): this {
|
|
||||||
return this.writeVarint((fieldNumber << 3) | wireType);
|
|
||||||
}
|
|
||||||
|
|
||||||
varint(fieldNumber: number, value: number): this {
|
|
||||||
return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed32(fieldNumber: number, value: number): this {
|
|
||||||
this.writeKey(fieldNumber, WIRE_FIXED32);
|
|
||||||
const v = value >>> 0;
|
|
||||||
this.buf.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes(fieldNumber: number, data: ArrayLike<number>): this {
|
|
||||||
this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length);
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
this.buf.push(data[i]! & 0xff);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
string(fieldNumber: number, value: string): this {
|
|
||||||
return this.bytes(fieldNumber, new TextEncoder().encode(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
message(fieldNumber: number, build: (writer: ProtobufWriter) => void): this {
|
|
||||||
const sub = new ProtobufWriter();
|
|
||||||
build(sub);
|
|
||||||
return this.bytes(fieldNumber, sub.toBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
toBytes(): Uint8Array<ArrayBuffer> {
|
|
||||||
return Uint8Array.from(this.buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { hash } from "./index.ts";
|
|
||||||
import { getArgus } from "./argus.ts";
|
|
||||||
import { getLadon } from "./ladon.ts";
|
|
||||||
import { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
|
|
||||||
export interface UnidbgHeaders extends Record<string, string> {
|
|
||||||
"x-argus": string;
|
|
||||||
"x-ladon": string;
|
|
||||||
"x-khronos": string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function generateHeaders(
|
|
||||||
rawQuery: string,
|
|
||||||
xssStub = "",
|
|
||||||
timestamp: number = Math.floor(Date.now() / 1000),
|
|
||||||
config: UnidbgConfig = defaultUnidbgConfig,
|
|
||||||
): Promise<UnidbgHeaders> {
|
|
||||||
const [argus, ladon] = await Promise.all([
|
|
||||||
getArgus(rawQuery, xssStub, timestamp, config),
|
|
||||||
getLadon(timestamp, config),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"x-argus": argus,
|
|
||||||
"x-ladon": ladon,
|
|
||||||
"x-khronos": String(timestamp),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signRequest(
|
|
||||||
url: string,
|
|
||||||
body?: ArrayBuffer | Uint8Array<ArrayBuffer> | string,
|
|
||||||
config: UnidbgConfig = defaultUnidbgConfig,
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
const rawQuery = new URL(url).search.replace(/^\?/, "");
|
|
||||||
|
|
||||||
const hasBody = typeof body === "string" ? body.length > 0 : (body?.byteLength ?? 0) > 0;
|
|
||||||
const xssStub = hasBody ? await hash.md5(body!) : "";
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const headers: Record<string, string> = await generateHeaders(
|
|
||||||
rawQuery,
|
|
||||||
xssStub,
|
|
||||||
Math.floor(now / 1000),
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
headers["x-ss-req-ticket"] = String(now);
|
|
||||||
if (hasBody) {
|
|
||||||
headers["X-SS-STUB"] = xssStub;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { getArgus } from "./argus.ts";
|
|
||||||
export { getLadon } from "./ladon.ts";
|
|
||||||
export { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
const ROUNDS = 72;
|
|
||||||
const MASK64 = 0xffffffffffffffffn;
|
|
||||||
|
|
||||||
const Z4 = 0x3dc94c3a046d678bn;
|
|
||||||
|
|
||||||
function getBit(value: bigint, position: number): bigint {
|
|
||||||
return (value >> BigInt(position)) & 1n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateLeft64(v: bigint, n: bigint): bigint {
|
|
||||||
return ((v << n) | (v >> (64n - n))) & MASK64;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateRight64(v: bigint, n: bigint): bigint {
|
|
||||||
return ((v << (64n - n)) | (v >> n)) & MASK64;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyExpansion(key: readonly [bigint, bigint, bigint, bigint]): bigint[] {
|
|
||||||
const k: bigint[] = [key[0] & MASK64, key[1] & MASK64, key[2] & MASK64, key[3] & MASK64];
|
|
||||||
|
|
||||||
for (let i = 4; i < ROUNDS; i++) {
|
|
||||||
let tmp = rotateRight64(k[i - 1]!, 3n);
|
|
||||||
tmp ^= k[i - 3]!;
|
|
||||||
tmp ^= rotateRight64(tmp, 1n);
|
|
||||||
k.push((~k[i - 4]! ^ tmp ^ getBit(Z4, (i - 4) % 62) ^ 3n) & MASK64);
|
|
||||||
}
|
|
||||||
return k;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function simonEncrypt(
|
|
||||||
plaintext: readonly [bigint, bigint],
|
|
||||||
key: readonly [bigint, bigint, bigint, bigint],
|
|
||||||
): [bigint, bigint] {
|
|
||||||
const k = keyExpansion(key);
|
|
||||||
|
|
||||||
let x = plaintext[0] & MASK64;
|
|
||||||
let y = plaintext[1] & MASK64;
|
|
||||||
|
|
||||||
for (let i = 0; i < ROUNDS; i++) {
|
|
||||||
const tmp = y;
|
|
||||||
const f = rotateLeft64(y, 1n) & rotateLeft64(y, 8n);
|
|
||||||
y = (x ^ f ^ rotateLeft64(y, 2n) ^ k[i]!) & MASK64;
|
|
||||||
x = tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [x, y];
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js
|
|
||||||
|
|
||||||
type Bytes = ArrayLike<number>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 循环左移
|
|
||||||
*/
|
|
||||||
function rotl(value: number, shift: number): number {
|
|
||||||
const normalizedShift = shift & 31;
|
|
||||||
return (value << normalizedShift) | (value >>> (32 - normalizedShift));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 二进制异或运算
|
|
||||||
*/
|
|
||||||
function xor(left: Bytes, right: Bytes): Uint8Array {
|
|
||||||
if (left.length !== right.length) {
|
|
||||||
throw new Error("XOR inputs must have the same length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = new Uint8Array(left.length);
|
|
||||||
for (let i = 0; i < left.length; i++) {
|
|
||||||
result[i] = (left[i]! ^ right[i]!) & 0xff;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 压缩函数中的置换函数 P0(X) = X xor (X <<< 9) xor (X <<< 17)
|
|
||||||
*/
|
|
||||||
function p0(value: number): number {
|
|
||||||
return (value ^ rotl(value, 9)) ^ rotl(value, 17);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消息扩展中的置换函数 P1(X) = X xor (X <<< 15) xor (X <<< 23)
|
|
||||||
*/
|
|
||||||
function p1(value: number): number {
|
|
||||||
return (value ^ rotl(value, 15)) ^ rotl(value, 23);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算 SM3 摘要。
|
|
||||||
*/
|
|
||||||
export function sm3(input: Bytes): Uint8Array<ArrayBuffer> {
|
|
||||||
const inputLength = input.length;
|
|
||||||
const paddedLength = Math.ceil((inputLength + 9) / 64) * 64;
|
|
||||||
const message = new Uint8Array(paddedLength);
|
|
||||||
message.set(input);
|
|
||||||
message[inputLength] = 0x80;
|
|
||||||
|
|
||||||
const bitLength = BigInt(inputLength) * 8n;
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
message[paddedLength - 1 - i] = Number((bitLength >> BigInt(i * 8)) & 0xffn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataView = new DataView(message.buffer);
|
|
||||||
const state = new Uint32Array([
|
|
||||||
0x7380166f,
|
|
||||||
0x4914b2b9,
|
|
||||||
0x172442d7,
|
|
||||||
0xda8a0600,
|
|
||||||
0xa96f30bc,
|
|
||||||
0x163138aa,
|
|
||||||
0xe38dee4d,
|
|
||||||
0xb0fb0e4e,
|
|
||||||
]);
|
|
||||||
const words = new Uint32Array(68);
|
|
||||||
const expandedWords = new Uint32Array(64);
|
|
||||||
|
|
||||||
for (let block = 0; block < message.length / 64; block++) {
|
|
||||||
const start = block * 64;
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
words[i] = dataView.getUint32(start + i * 4, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 16; i < 68; i++) {
|
|
||||||
words[i] = (p1((words[i - 16]! ^ words[i - 9]!) ^ rotl(words[i - 3]!, 15))
|
|
||||||
^ rotl(words[i - 13]!, 7))
|
|
||||||
^ words[i - 6]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
expandedWords[i] = words[i]! ^ words[i + 4]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
let a = state[0]!;
|
|
||||||
let b = state[1]!;
|
|
||||||
let c = state[2]!;
|
|
||||||
let d = state[3]!;
|
|
||||||
let e = state[4]!;
|
|
||||||
let f = state[5]!;
|
|
||||||
let g = state[6]!;
|
|
||||||
let h = state[7]!;
|
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const t = i <= 15 ? 0x79cc4519 : 0x7a879d8a;
|
|
||||||
const ss1 = rotl(rotl(a, 12) + e + rotl(t, i), 7);
|
|
||||||
const ss2 = ss1 ^ rotl(a, 12);
|
|
||||||
const tt1 = (i <= 15 ? (a ^ b) ^ c : ((a & b) | (a & c)) | (b & c))
|
|
||||||
+ d + ss2 + expandedWords[i]!;
|
|
||||||
const tt2 = (i <= 15 ? (e ^ f) ^ g : (e & f) | (~e & g))
|
|
||||||
+ h + ss1 + words[i]!;
|
|
||||||
|
|
||||||
d = c;
|
|
||||||
c = rotl(b, 9);
|
|
||||||
b = a;
|
|
||||||
a = tt1;
|
|
||||||
h = g;
|
|
||||||
g = rotl(f, 19);
|
|
||||||
f = e;
|
|
||||||
e = p0(tt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
state[0] = state[0]! ^ a;
|
|
||||||
state[1] = state[1]! ^ b;
|
|
||||||
state[2] = state[2]! ^ c;
|
|
||||||
state[3] = state[3]! ^ d;
|
|
||||||
state[4] = state[4]! ^ e;
|
|
||||||
state[5] = state[5]! ^ f;
|
|
||||||
state[6] = state[6]! ^ g;
|
|
||||||
state[7] = state[7]! ^ h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = new Uint8Array(32);
|
|
||||||
for (let i = 0; i < state.length; i++) {
|
|
||||||
const word = state[i]!;
|
|
||||||
result[i * 4] = word >>> 24;
|
|
||||||
result[i * 4 + 1] = word >>> 16;
|
|
||||||
result[i * 4 + 2] = word >>> 8;
|
|
||||||
result[i * 4 + 3] = word;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算 HMAC-SM3。
|
|
||||||
*/
|
|
||||||
export function hmac(input: Bytes, key: Bytes): Uint8Array<ArrayBuffer> {
|
|
||||||
const blockLength = 64;
|
|
||||||
const normalizedKey = key.length > blockLength ? sm3(key) : Uint8Array.from(key);
|
|
||||||
const paddedKey = new Uint8Array(blockLength);
|
|
||||||
paddedKey.set(normalizedKey);
|
|
||||||
|
|
||||||
const innerPad = new Uint8Array(blockLength).fill(0x36);
|
|
||||||
const outerPad = new Uint8Array(blockLength).fill(0x5c);
|
|
||||||
const innerKey = xor(paddedKey, innerPad);
|
|
||||||
const outerKey = xor(paddedKey, outerPad);
|
|
||||||
|
|
||||||
const innerMessage = new Uint8Array(blockLength + input.length);
|
|
||||||
innerMessage.set(innerKey);
|
|
||||||
innerMessage.set(input, blockLength);
|
|
||||||
|
|
||||||
const outerMessage = new Uint8Array(blockLength + 32);
|
|
||||||
outerMessage.set(outerKey);
|
|
||||||
outerMessage.set(sm3(innerMessage), blockLength);
|
|
||||||
return sm3(outerMessage);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { hash, pkcs7Pad } from "./index.ts";
|
|
||||||
|
|
||||||
const ROUNDS = 34;
|
|
||||||
const MASK64 = 0xffffffffffffffffn;
|
|
||||||
const WORD_SIZE = 64n;
|
|
||||||
const ALPHA = 8n;
|
|
||||||
const BETA = 3n;
|
|
||||||
|
|
||||||
function readUint64LE(view: DataView, offset: number): bigint {
|
|
||||||
return view.getBigUint64(offset, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function keySchedule(key: Uint8Array): bigint[] {
|
|
||||||
const view = new DataView(key.buffer, key.byteOffset, key.byteLength);
|
|
||||||
|
|
||||||
const ks: bigint[] = [readUint64LE(view, 0) & MASK64];
|
|
||||||
const numWords = (key.length * 8) / Number(WORD_SIZE);
|
|
||||||
const ls: bigint[] = [];
|
|
||||||
for (let i = 1; i < numWords; i++) {
|
|
||||||
ls.push(readUint64LE(view, i * 8) & MASK64);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let x = 0; x < ROUNDS - 1; x++) {
|
|
||||||
const rsX = (((ls[x]! << (WORD_SIZE - ALPHA)) + (ls[x]! >> ALPHA)) & MASK64);
|
|
||||||
const addSxy = (rsX + ks[x]!) & MASK64;
|
|
||||||
const newX = BigInt(x) ^ addSxy;
|
|
||||||
const lsY = (((ks[x]! >> (WORD_SIZE - BETA)) + (ks[x]! << BETA)) & MASK64);
|
|
||||||
ls.push(newX);
|
|
||||||
ks.push(newX ^ lsY);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ks;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function encryptBlock(ks: readonly bigint[], block: Uint8Array, out: Uint8Array, outOffset: number): void {
|
|
||||||
const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
|
|
||||||
let y = readUint64LE(view, 0);
|
|
||||||
let x = readUint64LE(view, 8);
|
|
||||||
|
|
||||||
for (const k of ks) {
|
|
||||||
const rsX = (((x << (WORD_SIZE - ALPHA)) + (x >> ALPHA)) & MASK64);
|
|
||||||
const addSxy = (rsX + y) & MASK64;
|
|
||||||
x = k ^ addSxy;
|
|
||||||
const lsY = (((y >> (WORD_SIZE - BETA)) + (y << BETA)) & MASK64);
|
|
||||||
y = x ^ lsY;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outView = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
|
||||||
outView.setBigUint64(outOffset, y & MASK64, true);
|
|
||||||
outView.setBigUint64(outOffset + 8, x & MASK64, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function speckEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
||||||
if (key.length !== 32) {
|
|
||||||
throw new Error(`Speck key must be 32 bytes, got ${key.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const padded = pkcs7Pad(plaintext, 16);
|
|
||||||
const ks = keySchedule(key);
|
|
||||||
|
|
||||||
const out = new Uint8Array(padded.length);
|
|
||||||
for (let i = 0; i < padded.length; i += 16) {
|
|
||||||
encryptBlock(ks, padded.subarray(i, i + 16), out, i);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function generateLadonKey(randomBytes: Uint8Array, aid: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
||||||
const aidBytes = new TextEncoder().encode(aid);
|
|
||||||
const input = new Uint8Array(randomBytes.length + aidBytes.length);
|
|
||||||
input.set(randomBytes);
|
|
||||||
input.set(aidBytes, randomBytes.length);
|
|
||||||
|
|
||||||
const hex = await hash.md5(input);
|
|
||||||
return new TextEncoder().encode(hex);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// TT 加密(设备注册请求体):gzip + SHA512 校验 + AES-128-CBC,magic 头 "tc"
|
|
||||||
// 移植自 fanqie-assistant src/crypto/ttencrypt.ts(Node crypto 实现)
|
|
||||||
import { createCipheriv, createHash, randomBytes } from 'node:crypto'
|
|
||||||
import { gzipSync } from 'node:zlib'
|
|
||||||
|
|
||||||
// 固定盐(base64 常量,社区共知)
|
|
||||||
const FIXED_STRING = Buffer.from(
|
|
||||||
'TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==',
|
|
||||||
'base64',
|
|
||||||
)
|
|
||||||
|
|
||||||
const sha512 = (data: Buffer): Buffer => createHash('sha512').update(data).digest()
|
|
||||||
|
|
||||||
export const encryptTT = (data: Buffer): Buffer => {
|
|
||||||
const rand = randomBytes(32)
|
|
||||||
const hashValue = sha512(Buffer.concat([sha512(rand), FIXED_STRING]))
|
|
||||||
const key = hashValue.subarray(0, 16)
|
|
||||||
const iv = hashValue.subarray(16, 32)
|
|
||||||
const compressed = gzipSync(data)
|
|
||||||
const payload = Buffer.concat([sha512(compressed), compressed])
|
|
||||||
const cipher = createCipheriv('aes-128-cbc', key, iv)
|
|
||||||
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()])
|
|
||||||
return Buffer.concat([Buffer.from([116, 99, 5, 16, 0, 0]), rand, encrypted])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { encryptTT }
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// 汽水音乐(luna, aid=386088)签名配置
|
|
||||||
// signKey/licenseId 是字节 metasec SDK 家族(v04.04.05-ov-android)共用密钥——经 ssovit(TikTok
|
|
||||||
// aid=1233)/fanqie-assistant(番茄 aid=1967)/本项目(汽水 aid=386088) 三方同源实证;per-app 的只有
|
|
||||||
// 载荷里的 msAppID(argus protobuf field 4,由 config.aid 提供)。metasec license blob(native
|
|
||||||
// SDK 运行时 init 用)不参与离线签名。
|
|
||||||
import { unhex } from "./index.ts";
|
|
||||||
|
|
||||||
export interface UnidbgConfig {
|
|
||||||
signKey: Uint8Array;
|
|
||||||
aid: string;
|
|
||||||
licenseId: string;
|
|
||||||
sdkVersion: string;
|
|
||||||
sdkVersionInt: number;
|
|
||||||
callType: number;
|
|
||||||
secDeviceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultUnidbgConfig: UnidbgConfig = {
|
|
||||||
signKey: new Uint8Array(
|
|
||||||
unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"),
|
|
||||||
),
|
|
||||||
aid: "386088",
|
|
||||||
licenseId: "1611921764",
|
|
||||||
sdkVersion: "v04.04.05-ov-android",
|
|
||||||
sdkVersionInt: 134744640,
|
|
||||||
callType: 738,
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// 汽水音乐设备注册 —— i.snssdk.com 统一设备服务(按 body 里的 aid 分 app)
|
|
||||||
// 接口: POST https://i.snssdk.com/service/2/device_register/?tt_data=a(TT 加密 body,免签名)
|
|
||||||
// 实测(2026-10-01)aid=386088 可注册成功,返回 device_id/install_id/device_token。
|
|
||||||
// 设备身份持久化于 data/auth.json 的 _qs_device 字段(复用注册=同一台"设备",避免重复注册)
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
|
||||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
|
||||||
import { encryptTT } from './crypto/ttencrypt.ts'
|
|
||||||
|
|
||||||
const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a'
|
|
||||||
|
|
||||||
// 与签名查询参数一致的设备画像(注册后不再变化)
|
|
||||||
const DEVICE_FINGERPRINT = {
|
|
||||||
device_type: 'ABR-AL80',
|
|
||||||
device_brand: 'HUAWEI',
|
|
||||||
device_model: 'ABR-AL80',
|
|
||||||
os_version: '15',
|
|
||||||
os_api: 35,
|
|
||||||
resolution: '1080*1920',
|
|
||||||
dpi: 480,
|
|
||||||
channel: 'xiaomi_8478_64',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANDROID_VERSIONS = ['10', '11', '12', '13', '14']
|
|
||||||
const HEX_LOW = '0123456789abcdef'
|
|
||||||
const pick = (list: readonly string[]) => list[Math.floor(Math.random() * list.length)]
|
|
||||||
const randomHex = (k: number) => Array.from({ length: k }, () => HEX_LOW[Math.floor(Math.random() * 16)]).join('')
|
|
||||||
const uuid = () => crypto.randomUUID()
|
|
||||||
|
|
||||||
export interface QsDevice {
|
|
||||||
device_id: string
|
|
||||||
install_id: string
|
|
||||||
device_token?: string
|
|
||||||
device_type: string
|
|
||||||
device_brand: string
|
|
||||||
device_model: string
|
|
||||||
os_version: string
|
|
||||||
channel: string
|
|
||||||
openudid: string
|
|
||||||
cdid: string
|
|
||||||
registeredAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildRequestBody = () => {
|
|
||||||
const osVersion = pick(ANDROID_VERSIONS)
|
|
||||||
const genTime = Date.now()
|
|
||||||
return {
|
|
||||||
magic_tag: 'ss_app_log',
|
|
||||||
header: {
|
|
||||||
display_name: '汽水音乐',
|
|
||||||
aid: 386088,
|
|
||||||
channel: DEVICE_FINGERPRINT.channel,
|
|
||||||
package: 'com.luna.music',
|
|
||||||
sdk_version: '3.7.0-rc.25-luna-music',
|
|
||||||
sdk_target_version: 29,
|
|
||||||
git_hash: '711d1a7',
|
|
||||||
density_dpi: DEVICE_FINGERPRINT.dpi,
|
|
||||||
display_density: 'xhdpi',
|
|
||||||
resolution: DEVICE_FINGERPRINT.resolution,
|
|
||||||
language: 'zh',
|
|
||||||
timezone: 8,
|
|
||||||
access: 'wifi',
|
|
||||||
not_request_sender: 0,
|
|
||||||
carrier: 'CHINA MOBILE',
|
|
||||||
mcc_mnc: '46000',
|
|
||||||
region: 'CN',
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
tz_offset: 28800,
|
|
||||||
sim_region: 'cn',
|
|
||||||
sim_serial_number: [],
|
|
||||||
oaid_may_support: false,
|
|
||||||
device_platform: 'android',
|
|
||||||
custom: { host_bit: 32 },
|
|
||||||
pre_installed_channel: '',
|
|
||||||
is_system_app: 0,
|
|
||||||
sdk_flavor: 'china',
|
|
||||||
guest_mode: 0,
|
|
||||||
os: 'Android',
|
|
||||||
os_version: osVersion,
|
|
||||||
os_api: DEVICE_FINGERPRINT.os_api,
|
|
||||||
device_model: DEVICE_FINGERPRINT.device_model,
|
|
||||||
device_brand: DEVICE_FINGERPRINT.device_brand,
|
|
||||||
device_manufacturer: DEVICE_FINGERPRINT.device_brand,
|
|
||||||
cpu_abi: 'arm64-v8a',
|
|
||||||
release_build: randomHex(7),
|
|
||||||
cdid: uuid(),
|
|
||||||
sig_hash: 'a4a27c2633195374c15651ffc3c4a497',
|
|
||||||
openudid: randomHex(20),
|
|
||||||
clientudid: uuid(),
|
|
||||||
req_id: uuid(),
|
|
||||||
rom: randomHex(14).toUpperCase(),
|
|
||||||
apk_first_install_time: genTime - Math.floor(Math.random() * 365) * 86_400_000,
|
|
||||||
ipv6_list: [
|
|
||||||
{ type: 'client_anpi', value: '::1' },
|
|
||||||
{ type: 'client_anpi', value: 'fd9d:8e11:8c31:2a2c:0:0:0:1' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
_gen_time: genTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册新设备并持久化 */
|
|
||||||
export const registerDevice = async (): Promise<QsDevice> => {
|
|
||||||
const body = buildRequestBody()
|
|
||||||
const encrypted = encryptTT(Buffer.from(JSON.stringify(body)))
|
|
||||||
const request = httpFetch(REGISTER_URL, {
|
|
||||||
method: 'post',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'okhttp/4.10.0',
|
|
||||||
'Content-Type': 'application/octet-stream; tt-data=a',
|
|
||||||
},
|
|
||||||
body: encrypted,
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
const { statusCode, body: resBody } = await request.promise
|
|
||||||
const data = resBody as any
|
|
||||||
if (statusCode != 200 || !data?.device_id_str || !data?.install_id_str) {
|
|
||||||
throw new Error(`设备注册失败: HTTP ${statusCode} ${JSON.stringify(data).slice(0, 120)}`)
|
|
||||||
}
|
|
||||||
const device: QsDevice = {
|
|
||||||
device_id: String(data.device_id_str),
|
|
||||||
install_id: String(data.install_id_str),
|
|
||||||
device_token: data.device_token ? String(data.device_token) : '',
|
|
||||||
device_type: DEVICE_FINGERPRINT.device_type,
|
|
||||||
device_brand: DEVICE_FINGERPRINT.device_brand,
|
|
||||||
device_model: DEVICE_FINGERPRINT.device_model,
|
|
||||||
os_version: DEVICE_FINGERPRINT.os_version,
|
|
||||||
channel: DEVICE_FINGERPRINT.channel,
|
|
||||||
openudid: body.header.openudid,
|
|
||||||
cdid: body.header.cdid,
|
|
||||||
}
|
|
||||||
setCredentials('_qs_device', device as any)
|
|
||||||
console.log(`[qs/device] 设备注册成功: device_id=${device.device_id}`)
|
|
||||||
return device
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedDevice: QsDevice | null = null
|
|
||||||
|
|
||||||
/** 取当前设备(无则注册并持久化) */
|
|
||||||
export const getDevice = async (): Promise<QsDevice> => {
|
|
||||||
if (cachedDevice) return cachedDevice
|
|
||||||
const stored = getCredentials('_qs_device') as QsDevice | null
|
|
||||||
if (stored?.device_id && stored?.install_id) {
|
|
||||||
cachedDevice = stored
|
|
||||||
return cachedDevice
|
|
||||||
}
|
|
||||||
cachedDevice = await registerDevice()
|
|
||||||
return cachedDevice
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 设备是否已注册(不触发注册) */
|
|
||||||
export const hasDevice = (): boolean =>
|
|
||||||
Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备身份(同步读,不触发注册):已注册设备优先;尚未注册时用独立生成的
|
|
||||||
* `_qs_passport_device`,避免覆盖注册记录。护照/签名模块共用,保证「同一设备」。
|
|
||||||
*/
|
|
||||||
export const getDeviceIds = (): { deviceId: string; installId: string } => {
|
|
||||||
const registered = getCredentials('_qs_device') as QsDevice | null
|
|
||||||
if (registered?.device_id && registered?.install_id) {
|
|
||||||
return { deviceId: String(registered.device_id), installId: String(registered.install_id) }
|
|
||||||
}
|
|
||||||
const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null
|
|
||||||
if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) }
|
|
||||||
const now = Date.now()
|
|
||||||
const ids = { deviceId: String(now), installId: String(now + 1) }
|
|
||||||
setCredentials('_qs_passport_device', ids)
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { registerDevice, getDevice, hasDevice, getDeviceIds }
|
|
||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
// 汽水音乐(字节跳动)平台模块
|
// 汽水音乐(字节跳动)平台模块
|
||||||
// 匿名可用面(2026-10-01 实测):搜索 / 曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。
|
// 匿名可用面(2026-10-01 实测):曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。
|
||||||
// 登录态(/login/qs/qr 抖音扫码):设备注册 + X-Argus/X-Ladon 自签走 /luna/pc/track_v2,
|
// 搜索与登录态取流需 PC 签名(QS_SIGN_SERVER 或固定值,见 sdk/qs/mssdk.ts 与 qs-signer 仓库):
|
||||||
|
// 登录态(/login/qs/qr 扫码)走 X-Helios/X-Medusa 签名 /luna/pc/track_v2,
|
||||||
// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测)
|
// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测)
|
||||||
import qsApi, { getStreamBuffer } from '../apis/qs.ts'
|
import qsApi, { getStreamBuffer } from '../apis/qs.ts'
|
||||||
import getLyric from './lyric.ts'
|
import getLyric from './lyric.ts'
|
||||||
|
|||||||
+162
-78
@@ -1,107 +1,186 @@
|
|||||||
// 汽水官方 PC 客户端签名(X-Helios / X-Medusa)—— 可选增强,缺原生模块时整体降级
|
// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 三种供给方式,按优先级:
|
||||||
//
|
// 1) 签名服务器(显式):环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:16889,
|
||||||
// 原理:直接复用官方客户端自带的原生模块 bdms.node(官方 main 进程就是
|
// 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。
|
||||||
// bdms.init({ deviceId }) → generateHttpSignatureHeaders(url, "名字\r\n值\r\n…"),
|
// 契约兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级)
|
||||||
// 把返回的 name/value 成对写回请求头)。与 sodahub-org/libmssdk 同一套做法,
|
// 的 mssdk-sign 服务(请求 {url, headers, body?},响应 {ok, headers, added?})。
|
||||||
// 区别是我们进程内加载,不另起 HTTP 签名服务。
|
// 2) 签名服务器(自动探测):QS_SIGN_SERVER 未配置时,进程启动探测本机 16689 端口——
|
||||||
|
// GET /healthz 应答 ok:true 且 service:"mssdk-sign" 才认定为汽水签名服务并自动接入
|
||||||
|
// (16689 上是别的服务则忽略,端口没人听静默跳过,均回落到 3)。
|
||||||
|
// 3) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 ——
|
||||||
|
// 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json:
|
||||||
|
// { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
|
||||||
|
// 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id
|
||||||
|
// (json 里带上即自动对齐 URL 参数),时效自负。
|
||||||
|
// 两者都没有:PC 签名不可用 —— 搜索/取流走 track_v2 的通道直接报错(匿名试听不受影响)。
|
||||||
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
|
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
|
||||||
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
|
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
|
||||||
//
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
// 原生文件(官方客户端二进制,专有、不入库、不再分发)放置位置任选其一:
|
|
||||||
// 1) 环境变量 QS_MSSDK_DIR 指向同时含 bdms.node 与 metasecml.dll 的目录
|
|
||||||
// 2) 仓库根 soda-mssdk/(已 gitignore)
|
|
||||||
// 3) 本机已安装的官方客户端:%LOCALAPPDATA%\Programs\Soda Music\<版本>\resources\app.asar.unpacked
|
|
||||||
// 从安装包提取:官方 SodaMusic-v*-official-win32_x64.exe → Bandizip 解 NSIS →
|
|
||||||
// $Var31/app.7z → resources/app.asar.unpacked/{bdms.node, metasecml.dll}
|
|
||||||
import { createRequire } from 'node:module'
|
|
||||||
import { existsSync, readdirSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { getDeviceIds } from './device.ts'
|
import { getDeviceIds } from './passport.ts'
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
|
|
||||||
const MODULE_NAME = 'bdms.node'
|
|
||||||
const CLIENT_DIR_NAMES = ['Soda Music', 'SodaMusic', '汽水音乐']
|
|
||||||
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
|
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
|
||||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||||
|
const FIXED_SIGN_FILE = join(REPO_ROOT, 'data', 'qs-sign.json')
|
||||||
|
const SIGN_TIMEOUT = 5000
|
||||||
|
|
||||||
const findModuleDir = (): string | null => {
|
// —— 端口探测:QS_SIGN_SERVER 未配置时,启动探测本机 16689 是不是汽水签名服务 ——
|
||||||
const candidates: string[] = []
|
const PROBE_BASE = 'http://127.0.0.1:16689'
|
||||||
if (process.env.QS_MSSDK_DIR) candidates.push(process.env.QS_MSSDK_DIR)
|
const PROBE_TIMEOUT = 2000
|
||||||
candidates.push(join(REPO_ROOT, 'soda-mssdk'))
|
|
||||||
const localAppData = process.env.LOCALAPPDATA
|
// undefined=探测未完成;null=探测过、不是签名服务;字符串=探测到的签名服务地址
|
||||||
if (localAppData) {
|
let probedServer: string | null | undefined
|
||||||
for (const name of CLIENT_DIR_NAMES) {
|
let probing: Promise<void> | null = null
|
||||||
const root = join(localAppData, 'Programs', name)
|
|
||||||
if (!existsSync(root)) continue
|
/** healthz 应答 ok:true 且 service:"mssdk-sign" 才认——16689 上可能是无关服务 */
|
||||||
|
const isSignService = (json: any): boolean => json?.ok === true && json?.service === 'mssdk-sign'
|
||||||
|
|
||||||
|
const probeOnce = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
for (const ver of readdirSync(root)) candidates.push(join(root, ver, 'resources', 'app.asar.unpacked'))
|
const res = await fetch(`${PROBE_BASE}/healthz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT) })
|
||||||
|
const json = await res.json().catch(() => null) as any
|
||||||
|
if (res.ok && isSignService(json)) {
|
||||||
|
probedServer = PROBE_BASE
|
||||||
|
console.log(`[qs/mssdk] 探测到本机 16689 是汽水签名服务(mssdk-sign v${json.version ?? '?'},dll ${json.dllVersion ?? '?'}),自动接入`)
|
||||||
|
} else {
|
||||||
|
probedServer = null
|
||||||
|
console.log(`[qs/mssdk] 本机 16689 有服务但不是汽水签名服务(healthz service=${json?.service ?? '非 JSON'}),忽略`)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 读不到目录就跳过
|
probedServer = null // 端口没人监听是常态,静默
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates.find(dir => dir && existsSync(join(dir, MODULE_NAME))) ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BdmsModule {
|
/** 确保探测完成(进程内只探一次);显式配置了 QS_SIGN_SERVER 则永不探测 */
|
||||||
init: (options: { deviceId: string }) => unknown
|
const ensureProbe = async (): Promise<void> => {
|
||||||
generateHttpSignatureHeaders: (url: string, headersLines: string) => string
|
if (probedServer !== undefined || process.env.QS_SIGN_SERVER?.trim()) return
|
||||||
|
probing ??= probeOnce()
|
||||||
|
await probing
|
||||||
}
|
}
|
||||||
|
|
||||||
let signer: { module: BdmsModule; deviceId: string; dir: string } | null | undefined
|
// 启动即探测一次(异步进行,不阻塞导入);探测期间进来的签名调用会等它出结果
|
||||||
|
void ensureProbe()
|
||||||
|
|
||||||
/** 懒加载官方签名模块(失败只提示一次,之后一律按不可用处理) */
|
/** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */
|
||||||
const loadSigner = () => {
|
interface FixedSignConfig {
|
||||||
if (signer !== undefined) return signer
|
helios: string
|
||||||
signer = null
|
medusa: string
|
||||||
|
deviceId: string
|
||||||
|
installId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixedConfigWarned = false
|
||||||
|
|
||||||
|
/** 读固定值:环境变量 QS_HELIOS/QS_MEDUSA 优先,其次 data/qs-sign.json;没有返回 null */
|
||||||
|
const readFixedConfig = (): FixedSignConfig | null => {
|
||||||
|
const envHelios = process.env.QS_HELIOS?.trim()
|
||||||
|
const envMedusa = process.env.QS_MEDUSA?.trim()
|
||||||
|
if (envHelios && envMedusa) {
|
||||||
|
return {
|
||||||
|
helios: envHelios,
|
||||||
|
medusa: envMedusa,
|
||||||
|
deviceId: process.env.QS_SIGN_DEVICE_ID?.trim() ?? '',
|
||||||
|
installId: process.env.QS_SIGN_INSTALL_ID?.trim() ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!existsSync(FIXED_SIGN_FILE)) return null
|
||||||
try {
|
try {
|
||||||
const dir = findModuleDir()
|
const data = JSON.parse(readFileSync(FIXED_SIGN_FILE, 'utf8')) as Record<string, unknown>
|
||||||
if (!dir) return signer
|
const helios = String(data.helios ?? '').trim()
|
||||||
const mod = require(join(dir, MODULE_NAME)) as BdmsModule
|
const medusa = String(data.medusa ?? '').trim()
|
||||||
if (typeof mod?.init !== 'function' || typeof mod?.generateHttpSignatureHeaders !== 'function') {
|
if (helios && medusa) {
|
||||||
console.log(`[qs/mssdk] ${MODULE_NAME} 导出不符合预期,按不可用处理`)
|
return {
|
||||||
return signer
|
helios,
|
||||||
|
medusa,
|
||||||
|
deviceId: String(data.device_id ?? '').trim(),
|
||||||
|
installId: String(data.install_id ?? '').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fixedConfigWarned) {
|
||||||
|
fixedConfigWarned = true
|
||||||
|
console.log(`[qs/mssdk] ${FIXED_SIGN_FILE} 缺少 helios/medusa 字段,固定值模式不启用`)
|
||||||
}
|
}
|
||||||
const { deviceId } = getDeviceIds()
|
|
||||||
mod.init({ deviceId })
|
|
||||||
signer = { module: mod, deviceId, dir }
|
|
||||||
console.log(`[qs/mssdk] 官方签名模块已加载: ${dir} (device_id=${deviceId})`)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log(`[qs/mssdk] 官方签名模块不可用,降级: ${err?.message ?? err}`)
|
if (!fixedConfigWarned) {
|
||||||
|
fixedConfigWarned = true
|
||||||
|
console.log(`[qs/mssdk] 固定值配置解析失败: ${err?.message ?? err}`)
|
||||||
}
|
}
|
||||||
return signer
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 官方签名是否可用(不影响主流程,仅决定走 PC 通道还是安卓通道) */
|
type SignMode =
|
||||||
export const mssdkAvailable = (): boolean => Boolean(loadSigner())
|
| { kind: 'server'; server: string; token: string }
|
||||||
|
| { kind: 'fixed'; fixed: FixedSignConfig }
|
||||||
|
|
||||||
/** 签名时绑定的设备(与请求 URL 里的 device_id/iid 必须一致) */
|
/** 当前签名供给方式:显式服务器 > 探测接入的服务器 > 固定值 > 不可用(null) */
|
||||||
export const mssdkDeviceId = (): string => loadSigner()?.deviceId ?? ''
|
const loadSignMode = (): SignMode | null => {
|
||||||
|
const server = process.env.QS_SIGN_SERVER?.trim().replace(/\/+$/, '')
|
||||||
|
if (server) return { kind: 'server', server, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' }
|
||||||
|
if (probedServer) {
|
||||||
|
// 探测接入;签名服务开了鉴权时同样吃 QS_SIGN_TOKEN
|
||||||
|
return { kind: 'server', server: probedServer, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' }
|
||||||
|
}
|
||||||
|
const fixed = readFixedConfig()
|
||||||
|
return fixed ? { kind: 'fixed', fixed } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PC 签名是否可用(只看配置与探测结果;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */
|
||||||
|
export const mssdkAvailable = (): boolean =>
|
||||||
|
// 探测未完成时先放行——紧随其后的 signQishuiRequest 会等探测结果再定供给
|
||||||
|
Boolean(loadSignMode()) || probedServer === undefined
|
||||||
|
|
||||||
|
const signViaServer = async (
|
||||||
|
server: string,
|
||||||
|
token: string,
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body?: string,
|
||||||
|
): Promise<Record<string, string> | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${server}/sign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url, headers, body }),
|
||||||
|
signal: AbortSignal.timeout(SIGN_TIMEOUT),
|
||||||
|
})
|
||||||
|
const json = await res.json().catch(() => null) as any
|
||||||
|
if (!res.ok || !json?.ok) {
|
||||||
|
console.log(`[qs/mssdk] 签名服务返回异常: HTTP ${res.status} ${json?.error ?? ''}`.trim())
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// signer/server.mjs 把 x-ss-stub 并进 headers,mssdk-sign 放 added —— 合并即可
|
||||||
|
const signed: Record<string, string> = { ...(json.headers ?? {}), ...(json.added ?? {}) }
|
||||||
|
if (!signed['X-Helios'] && !signed['x-helios']) {
|
||||||
|
console.log('[qs/mssdk] 签名结果缺少 X-Helios')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return signed
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[qs/mssdk] 签名服务不可达: ${err?.message ?? err}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给一个 qishui.com 请求签名
|
* 给一个 qishui.com 请求签名
|
||||||
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
|
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
|
||||||
* @returns 需追加的头;不可用时返回 null(调用方走原通道)
|
* @param body POST body(给了且 headers 没带 x-ss-stub 时服务端会自动补)
|
||||||
|
* @returns 需追加的头;不可用时返回 null(调用方自行降级处理)
|
||||||
*/
|
*/
|
||||||
export const signQishuiRequest = (url: string, headers: Record<string, string>): Record<string, string> | null => {
|
export const signQishuiRequest = async (
|
||||||
const current = loadSigner()
|
url: string,
|
||||||
if (!current) return null
|
headers: Record<string, string>,
|
||||||
try {
|
body?: string,
|
||||||
const lines = Object.entries(headers)
|
): Promise<Record<string, string> | null> => {
|
||||||
.filter(([name, value]) => name && value != null)
|
await ensureProbe() // 探测未完成时先等它出结果,再决定供给方式
|
||||||
.map(([name, value]) => `${name}\r\n${value}`)
|
const mode = loadSignMode()
|
||||||
.join('\r\n')
|
if (!mode) return null
|
||||||
const fields = String(current.module.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(Boolean)
|
if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa }
|
||||||
const signed: Record<string, string> = {}
|
return signViaServer(mode.server, mode.token, url, headers, body)
|
||||||
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
|
|
||||||
if (!signed['X-Helios'] && !signed['x-helios']) return null
|
|
||||||
return signed
|
|
||||||
} catch (err: any) {
|
|
||||||
console.log(`[qs/mssdk] 签名失败,本次降级: ${err?.message ?? err}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 官方 PC 端 UA(签名与 UA 绑定同一客户端版本) */
|
/** 官方 PC 端 UA(签名与 UA 绑定同一客户端版本) */
|
||||||
@@ -109,7 +188,12 @@ export const PC_UA = 'LunaPC/3.7.0(452316191)'
|
|||||||
|
|
||||||
/** 官方 PC 端公共查询参数(与签名设备成套;track_v2 / search 等 luna_pc 接口共用) */
|
/** 官方 PC 端公共查询参数(与签名设备成套;track_v2 / search 等 luna_pc 接口共用) */
|
||||||
export const pcCommonParams = (): Record<string, string> => {
|
export const pcCommonParams = (): Record<string, string> => {
|
||||||
const { deviceId, installId } = getDeviceIds()
|
const mode = loadSignMode()
|
||||||
|
// 固定值是针对抓包设备签的,必须用配套设备;服务器现签则用本地设备(URL 带什么签什么)
|
||||||
|
const fixedDevice = mode?.kind === 'fixed' && mode.fixed.deviceId ? mode.fixed : null
|
||||||
|
const { deviceId, installId } = fixedDevice
|
||||||
|
? { deviceId: fixedDevice.deviceId, installId: fixedDevice.installId }
|
||||||
|
: getDeviceIds()
|
||||||
return {
|
return {
|
||||||
aid: '386088',
|
aid: '386088',
|
||||||
app_name: 'luna_pc',
|
app_name: 'luna_pc',
|
||||||
@@ -135,4 +219,4 @@ export const pcCommonParams = (): Record<string, string> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { mssdkAvailable, mssdkDeviceId, signQishuiRequest, pcCommonParams, PC_UA }
|
export default { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA }
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
// 逐字歌词(lyric.content)与试听播放信息(track_player.url_player_info / video_model)
|
// 逐字歌词(lyric.content)与试听播放信息(track_player.url_player_info / video_model)
|
||||||
// 注意:seo_track 属分享页/SEO 数据面,track_player 里只有 30 秒试听资源;
|
// 注意:seo_track 属分享页/SEO 数据面,track_player 里只有 30 秒试听资源;
|
||||||
// 完整音频需真机凭证走 /luna/pc/track_v2,且音频经 CENC 加密,见 apis/qs.ts 头注释
|
// 完整音频需真机凭证走 /luna/pc/track_v2,且音频经 CENC 加密,见 apis/qs.ts 头注释
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import type { RequestObj } from '../types.ts'
|
import type { RequestObj } from '../types.ts'
|
||||||
|
|
||||||
export default (trackId: string): RequestObj<any> => {
|
export default (trackId: string): RequestObj<any> => {
|
||||||
|
|||||||
+22
-82
@@ -1,68 +1,16 @@
|
|||||||
// 汽水音乐搜索(App 搜索接口直连)
|
// 汽水音乐搜索(App 搜索接口直连)
|
||||||
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
||||||
// 两条通道(同一接口,差在公共参数与签名,返回结构一致):
|
// 官方 PC 通道: luna_pc 参数 + X-Helios/X-Medusa 签名(签名服务器或固定值,见 sdk/qs/mssdk.ts)。
|
||||||
// 官方 PC 通道: 装好官方签名模块(sdk/qs/mssdk.ts)时优先——luna_pc 参数 + X-Helios/X-Medusa;
|
// PC 签名不可用时搜索直接报错;被风控时上游返回 200 + 空 body(无 result_groups)。
|
||||||
// 安卓指纹通道: 未装签名模块时回退——静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控
|
|
||||||
// (200 + 空 body);被风控时需换新指纹。
|
|
||||||
import { sizeFormate, formatPlayTime } from '../index.ts'
|
import { sizeFormate, formatPlayTime } from '../index.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { coverUrl } from './musicInfo.ts'
|
import { coverUrl } from './musicInfo.ts'
|
||||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts'
|
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts'
|
||||||
|
|
||||||
// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新)
|
|
||||||
const SEARCH_DEVICE_PARAMS = {
|
|
||||||
device_platform: 'android',
|
|
||||||
os: 'android',
|
|
||||||
ssmix: 'a',
|
|
||||||
cdid: '46556f98-1720-4248-83da-62b74b60b46a',
|
|
||||||
channel: 'xiaomi_8478_64',
|
|
||||||
aid: '386088',
|
|
||||||
app_name: 'luna',
|
|
||||||
version_code: '100198030',
|
|
||||||
version_name: '19.8.0',
|
|
||||||
manifest_version_code: '100198030',
|
|
||||||
update_version_code: '100198030',
|
|
||||||
resolution: '1080*1920',
|
|
||||||
dpi: '480',
|
|
||||||
device_type: 'ABR-AL80',
|
|
||||||
device_brand: 'HUAWEI',
|
|
||||||
language: 'zh',
|
|
||||||
os_api: '35',
|
|
||||||
os_version: '15',
|
|
||||||
ac: 'wifi',
|
|
||||||
device_model: 'ABR-AL80',
|
|
||||||
save_power: '0',
|
|
||||||
font_size: '1.00',
|
|
||||||
luna_first_launch_apk_type: 'normal_apk',
|
|
||||||
diversion_channel_name: 'xiaomi_8478_64',
|
|
||||||
is_car_play: '0',
|
|
||||||
battery: '0.99',
|
|
||||||
network_speed: '10156',
|
|
||||||
hybrid_version_code: '100198030',
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
tz_offset: '28800',
|
|
||||||
luna_register_time: '1784311292',
|
|
||||||
package: 'com.luna.music',
|
|
||||||
charge: '0',
|
|
||||||
luna_apk_type: 'normal_apk',
|
|
||||||
output_device_type: 'Phone',
|
|
||||||
volume: '1.00',
|
|
||||||
brightness: '0.08',
|
|
||||||
need_personal_recommend: '1',
|
|
||||||
is_teen_mode: '0',
|
|
||||||
sim_region: 'cn',
|
|
||||||
android_device_type: 'default',
|
|
||||||
iid: '2204957404569386',
|
|
||||||
device_id: '2204957404565290',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
|
|
||||||
const PC_PAGE_SIZE = 20
|
const PC_PAGE_SIZE = 20
|
||||||
|
|
||||||
// 有官方签名模块时走官方 PC 通道(PC 参数必须配 X-Helios/X-Medusa,否则上游 200 + 空 body)
|
const buildSearchUrl = (str: string, cursor: number): string => {
|
||||||
const buildSearchUrl = (str: string, cursor: number, count: number) => {
|
const params = new URLSearchParams({
|
||||||
const params = mssdkAvailable()
|
|
||||||
? new URLSearchParams({
|
|
||||||
...pcCommonParams(),
|
...pcCommonParams(),
|
||||||
q: str,
|
q: str,
|
||||||
cursor: String(cursor),
|
cursor: String(cursor),
|
||||||
@@ -73,13 +21,6 @@ const buildSearchUrl = (str: string, cursor: number, count: number) => {
|
|||||||
search_scene: 'normal',
|
search_scene: 'normal',
|
||||||
scene_name: 'search',
|
scene_name: 'search',
|
||||||
})
|
})
|
||||||
: new URLSearchParams({
|
|
||||||
...SEARCH_DEVICE_PARAMS,
|
|
||||||
_rticket: String(Date.now()),
|
|
||||||
q: str,
|
|
||||||
cursor: String(cursor),
|
|
||||||
count: String(count),
|
|
||||||
})
|
|
||||||
return `https://api.qishui.com/luna/search/track?${params.toString()}`
|
return `https://api.qishui.com/luna/search/track?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,22 +59,8 @@ export default {
|
|||||||
}
|
}
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
},
|
},
|
||||||
search(str, page = 1, limit, retryNum = 0) {
|
handleSearchBody(rawBody, cursor, page) {
|
||||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
const group = rawBody.result_groups.find(g => g?.data?.length) || { data: [], has_more: false }
|
||||||
const pcChannel = mssdkAvailable()
|
|
||||||
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
|
|
||||||
const pageSize = pcChannel ? PC_PAGE_SIZE : (limit ?? this.limit)
|
|
||||||
const cursor = pageSize * (page - 1)
|
|
||||||
const url = buildSearchUrl(str, cursor, pageSize)
|
|
||||||
// 签名覆盖真实发出的头:先定头、再签、再发
|
|
||||||
const headers: Record<string, string> = { 'User-Agent': pcChannel ? PC_UA : SEARCH_UA }
|
|
||||||
const signed = signQishuiRequest(url, headers)
|
|
||||||
if (signed) Object.assign(headers, signed)
|
|
||||||
const searchRequest = httpFetch(url, { method: 'get', headers, timeout: 15000 })
|
|
||||||
return searchRequest.promise.then(({ body }) => {
|
|
||||||
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}),重试
|
|
||||||
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
|
|
||||||
const group = body.result_groups.find(g => g?.data?.length) || { data: [], has_more: false }
|
|
||||||
const list = this.handleResult(group.data)
|
const list = this.handleResult(group.data)
|
||||||
const total = group.has_more ? cursor + list.length + 1 : cursor + list.length
|
const total = group.has_more ? cursor + list.length + 1 : cursor + list.length
|
||||||
this.total = total
|
this.total = total
|
||||||
@@ -144,8 +71,21 @@ export default {
|
|||||||
allPage: this.allPage,
|
allPage: this.allPage,
|
||||||
limit: this.limit,
|
limit: this.limit,
|
||||||
total,
|
total,
|
||||||
source: 'qs',
|
source: 'qs' as const,
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
async search(str, page = 1) {
|
||||||
|
if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)')
|
||||||
|
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
|
||||||
|
const cursor = PC_PAGE_SIZE * (page - 1)
|
||||||
|
const url = buildSearchUrl(str, cursor)
|
||||||
|
// 签名覆盖真实发出的头:先定头、再签、再发
|
||||||
|
const headers: Record<string, string> = { 'User-Agent': PC_UA }
|
||||||
|
const signed = await signQishuiRequest(url, headers)
|
||||||
|
if (!signed) throw new Error('官方签名失败')
|
||||||
|
const { body } = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise
|
||||||
|
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}})
|
||||||
|
if (!body?.result_groups) throw new Error('搜索无结果(疑似风控)')
|
||||||
|
return this.handleSearchBody(body, cursor, page)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
+105
-2
@@ -11,7 +11,6 @@ import os from 'node:os'
|
|||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { httpFetch } from '../common/request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
import { getCredentials, setCredentials } from '../auth/store.ts'
|
||||||
import { getDeviceIds } from './device.ts'
|
|
||||||
|
|
||||||
const PASSPORT_HOST = 'https://api.qishui.com'
|
const PASSPORT_HOST = 'https://api.qishui.com'
|
||||||
const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login'
|
const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login'
|
||||||
@@ -30,6 +29,19 @@ const PASSPORT_FIXED = {
|
|||||||
version_code: '30020100',
|
version_code: '30020100',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 设备身份(同步读,首次懒生成并持久化):passport 登录与 PC 签名公共参数共用,
|
||||||
|
* 持久化于 data/auth.json 的 `_qs_passport_device`,保证同一实例「同一设备」。
|
||||||
|
*/
|
||||||
|
export const getDeviceIds = (): { deviceId: string; installId: string } => {
|
||||||
|
const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null
|
||||||
|
if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) }
|
||||||
|
const now = Date.now()
|
||||||
|
const ids = { deviceId: String(now), installId: String(now + 1) }
|
||||||
|
setCredentials('_qs_passport_device', ids)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
// 各 passport 请求共用的公共参数(设备身份 + 平台/版本)
|
// 各 passport 请求共用的公共参数(设备身份 + 平台/版本)
|
||||||
const commonParams = (): Record<string, string> => {
|
const commonParams = (): Record<string, string> => {
|
||||||
const { deviceId, installId } = getDeviceIds()
|
const { deviceId, installId } = getDeviceIds()
|
||||||
@@ -260,7 +272,98 @@ export const status = (): { logged: boolean; updatedAt?: string } => {
|
|||||||
/** 登录态 sessionid(未登录返回空串) */
|
/** 登录态 sessionid(未登录返回空串) */
|
||||||
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
|
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
|
||||||
|
|
||||||
|
// ---------- 手机验证码登录(接口与官方 PC 客户端 login.asar 的 WebInterfaceSdk 同款)----------
|
||||||
|
// POST /passport/web/send_code/ { mobile: "86 13800138000", type: 24 }
|
||||||
|
// POST /passport/web/sms_login/ { mobile, code } → Set-Cookie sessionid
|
||||||
|
|
||||||
|
/** 官方 SDK 的 mobile 参数是 "区号 号码" 空格拼接(如 "86 13800138000") */
|
||||||
|
const normalizeMobile = (mobile: string, cc = '86'): string => {
|
||||||
|
const s = String(mobile || '').trim()
|
||||||
|
const withCc = s.match(/^(\d{1,3})[\s-]+(\d{4,15})$/)
|
||||||
|
if (withCc) return `${withCc[1]} ${withCc[2]}`
|
||||||
|
return `${cc} ${s.replace(/\D/g, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const smsUpstream = async (path: string, form: Record<string, string>, cookie = ''): Promise<{ data: Record<string, any>; message: string; headers: Record<string, any> }> => {
|
||||||
|
const query = new URLSearchParams(commonParams())
|
||||||
|
const request = httpFetch(`${PASSPORT_HOST}${path}?${query}`, {
|
||||||
|
method: 'post',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...webHeaders(cookie) },
|
||||||
|
body: new URLSearchParams(form).toString(),
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
const { statusCode, headers, body } = await request.promise
|
||||||
|
if (statusCode != 200) throw Object.assign(new Error(`汽水接口 HTTP ${statusCode}`), { status: 502 })
|
||||||
|
return { data: (body as any)?.data ?? {}, message: String((body as any)?.message ?? ''), headers }
|
||||||
|
}
|
||||||
|
|
||||||
|
// send_code 下发的 cookie(验证码会话),sms_login 时带上
|
||||||
|
const smsCookies = new Map<string, string>()
|
||||||
|
|
||||||
|
export const sendSmsCode = async (mobile: string, cc?: string): Promise<{ sent: true; mobile: string }> => {
|
||||||
|
const norm = normalizeMobile(mobile, cc)
|
||||||
|
const { data, message, headers } = await smsUpstream('/passport/web/send_code/', {
|
||||||
|
mobile: norm,
|
||||||
|
type: '24', // 官方登录页固定 24(登录注册);16 为仅注册
|
||||||
|
})
|
||||||
|
if (message !== 'success') {
|
||||||
|
throw Object.assign(new Error(data.description || `验证码发送失败 (code=${data.error_code})`), { status: 400 })
|
||||||
|
}
|
||||||
|
smsCookies.set(norm, mergeCookies('', cookieMapFrom(headers)))
|
||||||
|
return { sent: true, mobile: norm }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 官方登录页的失败文案映射(login-7a4b1afd.js catch 分支)
|
||||||
|
const smsFailMessage = (data: Record<string, any>): string => {
|
||||||
|
if (data.error_code === 1075) return '账号注销中,请前往抖音放弃注销'
|
||||||
|
if (data.error_code === 2046) return '需要二次验证,请在汽水 App 完成安全验证后重试'
|
||||||
|
return data.description || `登录失败 (code=${data.error_code})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const smsLogin = async (mobile: string, code: string, cc?: string): Promise<Record<string, any>> => {
|
||||||
|
const norm = normalizeMobile(mobile, cc)
|
||||||
|
const { data, message, headers } = await smsUpstream('/passport/web/sms_login/', {
|
||||||
|
mobile: norm,
|
||||||
|
code: String(code ?? '').trim(),
|
||||||
|
}, smsCookies.get(norm) || '')
|
||||||
|
|
||||||
|
let cookie = mergeCookies(smsCookies.get(norm) || '', cookieMapFrom(headers))
|
||||||
|
smsCookies.delete(norm)
|
||||||
|
|
||||||
|
// 成功但 sessionid 未随本响应下发时补跳 redirect_url(与扫码确认同款)
|
||||||
|
if (message === 'success' && !sessionIdOf(cookie) && data.redirect_url) {
|
||||||
|
try {
|
||||||
|
const hop = await httpFetch(String(data.redirect_url), { method: 'get', headers: webHeaders(cookie), timeout: 15000 }).promise
|
||||||
|
cookie = mergeCookies(cookie, cookieMapFrom(hop.headers))
|
||||||
|
} catch {
|
||||||
|
// 补跳失败不致命:sessionid 缺失时按失败处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionid = sessionIdOf(cookie)
|
||||||
|
if (message === 'success' && sessionid) {
|
||||||
|
setCredentials('qs', { sessionid, cookie })
|
||||||
|
return { code: 803, message: '登录成功', errorCode: 0 }
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error(message === 'success' ? '登录响应缺少 sessionid' : smsFailMessage(data)), { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动绑定 sessionid(汽水 App / 官方 PC 客户端抓包,或已登录浏览器 Cookie)。
|
||||||
|
* 接受裸 sessionid 值或完整 cookie 串(须含 sessionid= 字段);完整串优先整体保存,
|
||||||
|
* sid_guard 等伴随字段有利于延长登录态寿命。passport 无可用的免签校验接口
|
||||||
|
* (/passport/web/user_info/ 对本 aid 恒报 20016 无权限),不做上游验证,失效会在取流时暴露。
|
||||||
|
*/
|
||||||
|
export const loginBySessionId = async(input: string): Promise<{ logged: true; sessionid: string }> => {
|
||||||
|
const raw = String(input || '').trim()
|
||||||
|
const sessionid = sessionIdOf(raw) || (/^[\w-]{10,}$/.test(raw) ? raw : '')
|
||||||
|
if (!sessionid) throw Object.assign(new Error('需提供 sessionid 值或含 sessionid= 字段的 cookie'), { status: 422 })
|
||||||
|
const cookie = raw.includes('=') ? raw : `sessionid=${sessionid}`
|
||||||
|
setCredentials('qs', { sessionid, cookie })
|
||||||
|
return { logged: true, sessionid }
|
||||||
|
}
|
||||||
|
|
||||||
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
|
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
|
||||||
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
|
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
|
||||||
|
|
||||||
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus }
|
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus, sendSmsCode, smsLogin, loginBySessionId }
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// 蜻蜓FM SSR 请求助手 —— 域名迁移(www.qingting.fm → www.qtfm.cn)伴随华为 WAF 会话,
|
// 蜻蜓FM SSR 请求助手 —— 域名迁移(www.qingting.fm → www.qtfm.cn)伴随华为 WAF 会话,
|
||||||
// 302 时需带上 Set-Cookie 的 HWWAFSESID/HWWAFSESTIME 再请求 Location,否则可能被 WAF 拦
|
// 302 时需带上 Set-Cookie 的 HWWAFSESID/HWWAFSESTIME 再请求 Location,否则可能被 WAF 拦
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// SDK 根请求模块 —— 唯一实现在 common/request.ts,此处仅转发
|
|
||||||
// sdk 内 `import { ... } from '../request.ts'` 的解析目标
|
|
||||||
|
|
||||||
export { httpFetch } from './common/request.ts'
|
|
||||||
export type { HttpOptions, HttpRequestObject, HttpFetchResponse } from './common/request.ts'
|
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
// 曲目:music.musichallAlbum.AlbumSongList GetAlbumSongList 按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
|
// 曲目:music.musichallAlbum.AlbumSongList GetAlbumSongList 按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
|
||||||
// 详情:GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,封面走平台固定 URL 规则
|
// 详情:GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,封面走平台固定 URL 规则
|
||||||
// 链接解析:albumDetail/<mid>、album/<mid>、albummid=<mid>(上游 ParseAlbum)
|
// 链接解析:albumDetail/<mid>、album/<mid>、albummid=<mid>(上游 ParseAlbum)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { filterMusicInfoItem } from './singer.ts'
|
import { filterMusicInfoItem } from './singer.ts'
|
||||||
import { createMusicuFetch } from './utils/index.ts'
|
import { createMusicuFetch } from './utils/index.ts'
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { dateFormat2 } from '../index.ts'
|
import { dateFormat2 } from '../index.ts'
|
||||||
import getMusicInfo from './musicInfo.ts'
|
import getMusicInfo from './musicInfo.ts'
|
||||||
|
|
||||||
@@ -114,7 +114,6 @@ export default {
|
|||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
if (statusCode != 200 || body.code !== 0) throw new Error('获取评论失败')
|
if (statusCode != 200 || body.code !== 0) throw new Error('获取评论失败')
|
||||||
// console.log(body, statusCode)
|
|
||||||
const comment = body.comment
|
const comment = body.comment
|
||||||
return {
|
return {
|
||||||
source: 'tx',
|
source: 'tx',
|
||||||
@@ -183,7 +182,6 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj2.promise
|
const { body, statusCode } = await _requestObj2.promise
|
||||||
// console.log('body', body)
|
|
||||||
if (statusCode != 200 || body.code !== 0 || body.req.code !== 0) throw new Error('获取热门评论失败')
|
if (statusCode != 200 || body.code !== 0 || body.req.code !== 0) throw new Error('获取热门评论失败')
|
||||||
const comment = body.req.data.CommentList
|
const comment = body.req.data.CommentList
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
_requestObj: null as HttpRequestObject | null,
|
_requestObj: null as HttpRequestObject | null,
|
||||||
@@ -44,9 +44,7 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
// console.log(body)
|
|
||||||
if (statusCode != 200 || body.code !== 0) throw new Error('获取热搜词失败')
|
if (statusCode != 200 || body.code !== 0) throw new Error('获取热搜词失败')
|
||||||
// console.log(body)
|
|
||||||
return { source: 'tx', list: this.filterList(body.hotkey.data.vec_hotkey) }
|
return { source: 'tx', list: this.filterList(body.hotkey.data.vec_hotkey) }
|
||||||
},
|
},
|
||||||
filterList(rawList) {
|
filterList(rawList) {
|
||||||
|
|||||||
+3
-23
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequestObject, SongInfo, SongType, Quality, QualityInfo } from '../types.ts'
|
import type { HttpRequestObject, SongType, Quality, QualityInfo } from '../types.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||||
import { formatSingerName } from '../utils.ts'
|
import { formatSingerName } from '../utils.ts'
|
||||||
|
|
||||||
@@ -65,7 +65,6 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
listDetailRequest(id, period, limit) {
|
listDetailRequest(id, period, limit) {
|
||||||
// console.log(id, period, limit)
|
|
||||||
return httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
return httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -107,7 +106,6 @@ export default {
|
|||||||
return requestDataObj.promise
|
return requestDataObj.promise
|
||||||
},
|
},
|
||||||
filterData(rawList) {
|
filterData(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
return rawList.map(item => {
|
return rawList.map(item => {
|
||||||
let types: SongType[] = []
|
let types: SongType[] = []
|
||||||
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
let _types: Partial<Record<Quality, QualityInfo>> = {}
|
||||||
@@ -180,7 +178,6 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
filterBoardsData(rawList) {
|
filterBoardsData(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
let list: any[] = []
|
let list: any[] = []
|
||||||
for (const board of rawList) {
|
for (const board of rawList) {
|
||||||
// 排除 MV榜
|
// 排除 MV榜
|
||||||
@@ -198,24 +195,7 @@ export default {
|
|||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
async getBoards(retryNum = 0) {
|
async getBoards() {
|
||||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
|
||||||
// let response
|
|
||||||
// try {
|
|
||||||
// response = await this.getBoardsData()
|
|
||||||
// } catch (error) {
|
|
||||||
// return this.getBoards(retryNum)
|
|
||||||
// }
|
|
||||||
// // console.log(response.body)
|
|
||||||
// if (response.statusCode !== 200 || response.body.code !== 0) return this.getBoards(retryNum)
|
|
||||||
// const list = this.filterBoardsData(response.body.data.topList)
|
|
||||||
// console.log(list)
|
|
||||||
// console.log(JSON.stringify(list))
|
|
||||||
// this.list = list
|
|
||||||
// return {
|
|
||||||
// list,
|
|
||||||
// source: 'tx',
|
|
||||||
// }
|
|
||||||
this.list = boardList
|
this.list = boardList
|
||||||
return {
|
return {
|
||||||
list: boardList,
|
list: boardList,
|
||||||
|
|||||||
+1
-35
@@ -1,5 +1,4 @@
|
|||||||
import type { HttpRequestObject } from '../types.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
|
||||||
import getMusicInfo from './musicInfo.ts'
|
import getMusicInfo from './musicInfo.ts'
|
||||||
import { decodeQrc } from './qrcDecode.ts'
|
import { decodeQrc } from './qrcDecode.ts'
|
||||||
|
|
||||||
@@ -111,8 +110,6 @@ const parseTools = {
|
|||||||
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
|
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
|
||||||
},
|
},
|
||||||
fixRlrcTimeTag(rlrc, lrc) {
|
fixRlrcTimeTag(rlrc, lrc) {
|
||||||
// console.log(lrc)
|
|
||||||
// console.log(rlrc)
|
|
||||||
const rlrcLines = rlrc.split('\n')
|
const rlrcLines = rlrc.split('\n')
|
||||||
let lrcLines = lrc.split('\n')
|
let lrcLines = lrc.split('\n')
|
||||||
// let temp: string[] = []
|
// let temp: string[] = []
|
||||||
@@ -141,8 +138,6 @@ const parseTools = {
|
|||||||
return newLrc.join('\n')
|
return newLrc.join('\n')
|
||||||
},
|
},
|
||||||
fixTlrcTimeTag(tlrc, lrc) {
|
fixTlrcTimeTag(tlrc, lrc) {
|
||||||
// console.log(lrc)
|
|
||||||
// console.log(tlrc)
|
|
||||||
const tlrcLines = tlrc.split('\n')
|
const tlrcLines = tlrc.split('\n')
|
||||||
let lrcLines = lrc.split('\n')
|
let lrcLines = lrc.split('\n')
|
||||||
// let temp: string[] = []
|
// let temp: string[] = []
|
||||||
@@ -185,14 +180,9 @@ const parseTools = {
|
|||||||
let { lyric, yrc } = this.parseLyric(this.removeTag(lrc))
|
let { lyric, yrc } = this.parseLyric(this.removeTag(lrc))
|
||||||
info.lyric = lyric
|
info.lyric = lyric
|
||||||
info.yrc = yrc
|
info.yrc = yrc
|
||||||
// console.log(lyric)
|
|
||||||
// console.log(yrc)
|
|
||||||
}
|
}
|
||||||
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
|
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
|
||||||
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
|
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
|
||||||
// console.log(info.lyric)
|
|
||||||
// console.log(info.tlyric)
|
|
||||||
// console.log(info.rlyric)
|
|
||||||
|
|
||||||
return info
|
return info
|
||||||
},
|
},
|
||||||
@@ -218,9 +208,6 @@ export default {
|
|||||||
// return {
|
// return {
|
||||||
|
|
||||||
// }
|
// }
|
||||||
// console.log(lyric)
|
|
||||||
// console.log(tlyric)
|
|
||||||
// console.log(rlyric)
|
|
||||||
return parseTools.parse(lyric, tlyric, rlyric)
|
return parseTools.parse(lyric, tlyric, rlyric)
|
||||||
},
|
},
|
||||||
getLyric(mInfo, retryNum = 0) {
|
getLyric(mInfo, retryNum = 0) {
|
||||||
@@ -266,7 +253,6 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return requestObj.promise.then(({ body }) => {
|
return requestObj.promise.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (body.code != this.successCode || body.req.code != this.successCode) return this.getLyric(songId, ++retryNum)
|
if (body.code != this.successCode || body.req.code != this.successCode) return this.getLyric(songId, ++retryNum)
|
||||||
const data = body.req.data
|
const data = body.req.data
|
||||||
return this.parseLyric(data.lyric, data.trans, data.roma)
|
return this.parseLyric(data.lyric, data.trans, data.roma)
|
||||||
@@ -276,23 +262,3 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// export default {
|
|
||||||
// regexps: {
|
|
||||||
// matchLrc: /.+"lyric":"([\w=+/]*)".+/,
|
|
||||||
// },
|
|
||||||
// getLyric(songmid) {
|
|
||||||
// const requestObj = httpFetch(`https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid=${songmid}&g_tk=5381&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&platform=yqq`, {
|
|
||||||
// headers: {
|
|
||||||
// Referer: 'https://y.qq.com/portal/player.html',
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
|
||||||
// if (body.code != 0 || !body.lyric) return Promise.reject(new Error('Get lyric failed'))
|
|
||||||
// return {
|
|
||||||
// lyric: decodeName(b64DecodeUnicode(body.lyric)),
|
|
||||||
// tlyric: decodeName(b64DecodeUnicode(body.trans)),
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
// },
|
|
||||||
// }
|
|
||||||
+2
-3
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequestObject, SongType, Quality, QualityInfo } from '../types.ts'
|
import type { SongType, Quality, QualityInfo } from '../types.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||||
|
|
||||||
const getSinger = (singers) => {
|
const getSinger = (singers) => {
|
||||||
@@ -33,7 +33,6 @@ export default (songmid) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return requestObj.promise.then(({ body }) => {
|
return requestObj.promise.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (body.code != 0 || body.req.code != 0) return Promise.reject(new Error('获取歌曲信息失败'))
|
if (body.code != 0 || body.req.code != 0) return Promise.reject(new Error('获取歌曲信息失败'))
|
||||||
const item = body.req.data.track_info
|
const item = body.req.data.track_info
|
||||||
if (!item.file?.media_mid) return null
|
if (!item.file?.media_mid) return null
|
||||||
|
|||||||
@@ -61,26 +61,13 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return searchRequest.then(({ body }) => {
|
return searchRequest.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (!body || !body.req || body.code != this.successCode || body.req.code != this.successCode) {
|
if (!body || !body.req || body.code != this.successCode || body.req.code != this.successCode) {
|
||||||
return this.musicSearch(str, page, limit, ++retryNum)
|
return this.musicSearch(str, page, limit, ++retryNum)
|
||||||
}
|
}
|
||||||
return body.req.data
|
return body.req.data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
// randomInt(min, max) {
|
|
||||||
// return Math.floor(Math.random() * (max - min + 1)) + min
|
|
||||||
// },
|
|
||||||
// getSearchId() {
|
|
||||||
// const e = BigInt(this.randomInt(1, 20))
|
|
||||||
// const t = e * 18014398509481984n
|
|
||||||
// const n = BigInt(this.randomInt(0, 4194304)) * 4294967296n
|
|
||||||
// const a = BigInt(Date.now())
|
|
||||||
// const r = (a * 1000n) % (24n * 60n * 60n * 1000n)
|
|
||||||
// return String(t + n + r)
|
|
||||||
// },
|
|
||||||
handleResult(rawList) {
|
handleResult(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
if (!rawList || !Array.isArray(rawList)) return []
|
if (!rawList || !Array.isArray(rawList)) return []
|
||||||
const list: SongInfo[] = []
|
const list: SongInfo[] = []
|
||||||
rawList.forEach(item => {
|
rawList.forEach(item => {
|
||||||
@@ -144,7 +131,6 @@ export default {
|
|||||||
typeUrl: {},
|
typeUrl: {},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// console.log(list)
|
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
search(str, page = 1, limit) {
|
search(str, page = 1, limit) {
|
||||||
|
|||||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user