Compare commits
2
Commits
1b972ec4dd
...
8c02fb5c15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c02fb5c15 | ||
|
|
ecca1b8458 |
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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('5s') 读取(无则匿名)。
|
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5s') 读取(无则匿名)。
|
||||||
|
|
||||||
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'
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
// 统一接口约定:apis('jx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
// 统一接口约定:apis('jx').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,9 +2,9 @@
|
|||||||
// 命中可访问的最高档位;匿名仅 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 '../migu/util.ts'
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
+4
-4
@@ -8,7 +8,7 @@
|
|||||||
// 统一接口约定: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 { getDevice } from '../qs/device.ts'
|
||||||
@@ -156,10 +156,10 @@ const fetchTrackPlayerByApp = async (trackId: string) => {
|
|||||||
}, requestBody)
|
}, requestBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(需 sdk/qs/mssdk.ts 的签名模块)
|
// 通道 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 签名不可用')
|
||||||
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,7 +172,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+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)))
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
+2
-2
@@ -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'
|
||||||
|
|||||||
+1
-2
@@ -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'
|
||||||
|
|
||||||
@@ -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,
|
||||||
))
|
))
|
||||||
|
|||||||
+1
-1
@@ -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'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
|
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
|
||||||
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选,未登录留空)。
|
// 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'
|
||||||
|
|||||||
+122
-83
@@ -1,107 +1,141 @@
|
|||||||
// 汽水官方 PC 客户端签名(X-Helios / X-Medusa)—— 可选增强,缺原生模块时整体降级
|
// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 两种供给方式,按优先级:
|
||||||
//
|
// 1) 签名服务器:环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:8899,
|
||||||
// 原理:直接复用官方客户端自带的原生模块 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 同一套做法,
|
// 的两个服务(请求 {url, headers, body?},响应 {ok, headers, added?}):
|
||||||
// 区别是我们进程内加载,不另起 HTTP 签名服务。
|
// - server.mjs (Wine + bdms.node)
|
||||||
|
// - mssdk-sign/ (metasecml.dll 独立进程,见其 README)
|
||||||
|
// 2) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 ——
|
||||||
|
// 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json:
|
||||||
|
// { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
|
||||||
|
// 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id
|
||||||
|
// (json 里带上即自动对齐 URL 参数),时效自负,被风控时走安卓通道兜底。
|
||||||
|
// 两者都没有:PC 通道不可用,搜索/取流自动走安卓自签通道。
|
||||||
// 官方只对 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 './device.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 => {
|
/** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */
|
||||||
const candidates: string[] = []
|
interface FixedSignConfig {
|
||||||
if (process.env.QS_MSSDK_DIR) candidates.push(process.env.QS_MSSDK_DIR)
|
helios: string
|
||||||
candidates.push(join(REPO_ROOT, 'soda-mssdk'))
|
medusa: string
|
||||||
const localAppData = process.env.LOCALAPPDATA
|
deviceId: string
|
||||||
if (localAppData) {
|
installId: string
|
||||||
for (const name of CLIENT_DIR_NAMES) {
|
}
|
||||||
const root = join(localAppData, 'Programs', name)
|
|
||||||
if (!existsSync(root)) continue
|
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 {
|
||||||
for (const ver of readdirSync(root)) candidates.push(join(root, ver, 'resources', 'app.asar.unpacked'))
|
const data = JSON.parse(readFileSync(FIXED_SIGN_FILE, 'utf8')) as Record<string, unknown>
|
||||||
} catch {
|
const helios = String(data.helios ?? '').trim()
|
||||||
// 读不到目录就跳过
|
const medusa = String(data.medusa ?? '').trim()
|
||||||
|
if (helios && medusa) {
|
||||||
|
return {
|
||||||
|
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 字段,固定值模式不启用`)
|
||||||
}
|
}
|
||||||
return candidates.find(dir => dir && existsSync(join(dir, MODULE_NAME))) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BdmsModule {
|
|
||||||
init: (options: { deviceId: string }) => unknown
|
|
||||||
generateHttpSignatureHeaders: (url: string, headersLines: string) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
let signer: { module: BdmsModule; deviceId: string; dir: string } | null | undefined
|
|
||||||
|
|
||||||
/** 懒加载官方签名模块(失败只提示一次,之后一律按不可用处理) */
|
|
||||||
const loadSigner = () => {
|
|
||||||
if (signer !== undefined) return signer
|
|
||||||
signer = null
|
|
||||||
try {
|
|
||||||
const dir = findModuleDir()
|
|
||||||
if (!dir) return signer
|
|
||||||
const mod = require(join(dir, MODULE_NAME)) as BdmsModule
|
|
||||||
if (typeof mod?.init !== 'function' || typeof mod?.generateHttpSignatureHeaders !== 'function') {
|
|
||||||
console.log(`[qs/mssdk] ${MODULE_NAME} 导出不符合预期,按不可用处理`)
|
|
||||||
return signer
|
|
||||||
}
|
|
||||||
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() ?? '' }
|
||||||
|
const fixed = readFixedConfig()
|
||||||
|
return fixed ? { kind: 'fixed', fixed } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PC 签名是否可用(只看配置;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */
|
||||||
|
export const mssdkAvailable = (): boolean => Boolean(loadSignMode())
|
||||||
|
|
||||||
|
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)
|
const mode = loadSignMode()
|
||||||
.map(([name, value]) => `${name}\r\n${value}`)
|
if (!mode) return null
|
||||||
.join('\r\n')
|
if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa }
|
||||||
const fields = String(current.module.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(Boolean)
|
return signViaServer(mode.server, mode.token, url, headers, body)
|
||||||
const signed: Record<string, string> = {}
|
|
||||||
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 +143,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 +174,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> => {
|
||||||
|
|||||||
+47
-24
@@ -1,11 +1,12 @@
|
|||||||
// 汽水音乐搜索(App 搜索接口直连)
|
// 汽水音乐搜索(App 搜索接口直连)
|
||||||
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
||||||
// 两条通道(同一接口,差在公共参数与签名,返回结构一致):
|
// 两条通道(同一接口,差在公共参数与签名,返回结构一致):
|
||||||
// 官方 PC 通道: 装好官方签名模块(sdk/qs/mssdk.ts)时优先——luna_pc 参数 + X-Helios/X-Medusa;
|
// 官方 PC 通道: PC 签名可用(签名服务器或固定值,见 sdk/qs/mssdk.ts)时优先——
|
||||||
// 安卓指纹通道: 未装签名模块时回退——静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控
|
// luna_pc 参数 + X-Helios/X-Medusa;签名失败/被风控自动降级安卓通道;
|
||||||
// (200 + 空 body);被风控时需换新指纹。
|
// 安卓指纹通道: 静态安卓设备参数,缺参数或用 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'
|
||||||
|
|
||||||
@@ -59,9 +60,9 @@ const SEARCH_DEVICE_PARAMS = {
|
|||||||
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
|
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)
|
// PC 通道必须配 X-Helios/X-Medusa(否则上游 200 + 空 body);安卓通道用静态设备指纹
|
||||||
const buildSearchUrl = (str: string, cursor: number, count: number) => {
|
const buildSearchUrl = (str: string, cursor: number, count: number, pcChannel: boolean) => {
|
||||||
const params = mssdkAvailable()
|
const params = pcChannel
|
||||||
? new URLSearchParams({
|
? new URLSearchParams({
|
||||||
...pcCommonParams(),
|
...pcCommonParams(),
|
||||||
q: str,
|
q: str,
|
||||||
@@ -118,22 +119,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
|
||||||
@@ -146,6 +133,42 @@ export default {
|
|||||||
total,
|
total,
|
||||||
source: 'qs',
|
source: 'qs',
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
async search(str, page = 1, limit, retryNum = 0, forceAndroid = false) {
|
||||||
|
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||||
|
const pcChannel = !forceAndroid && mssdkAvailable()
|
||||||
|
if (pcChannel) {
|
||||||
|
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
|
||||||
|
const pageSize = PC_PAGE_SIZE
|
||||||
|
const cursor = pageSize * (page - 1)
|
||||||
|
const url = buildSearchUrl(str, cursor, pageSize, true)
|
||||||
|
// 签名覆盖真实发出的头:先定头、再签、再发
|
||||||
|
const headers: Record<string, string> = { 'User-Agent': PC_UA }
|
||||||
|
const fallbackToAndroid = (reason: string) => {
|
||||||
|
console.log(`[qs/search] PC 通道${reason},降级安卓通道`)
|
||||||
|
return this.search(str, page, limit, retryNum, true)
|
||||||
|
}
|
||||||
|
let body: any = null
|
||||||
|
try {
|
||||||
|
// 签名失败(服务器宕机/固定值被风控)与请求异常都降级安卓通道
|
||||||
|
const signed = await signQishuiRequest(url, headers)
|
||||||
|
if (!signed) return fallbackToAndroid('签名不可用')
|
||||||
|
const searchRequest = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise
|
||||||
|
body = searchRequest.body
|
||||||
|
} catch (err: any) {
|
||||||
|
return fallbackToAndroid(`请求失败: ${err?.message ?? err}`)
|
||||||
|
}
|
||||||
|
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}})
|
||||||
|
if (!body?.result_groups) return fallbackToAndroid('空结果(疑似风控)')
|
||||||
|
return this.handleSearchBody(body, cursor, page)
|
||||||
|
}
|
||||||
|
// 安卓指纹通道:无签名直发,空结果时换 _rticket 重试
|
||||||
|
const pageSize = limit ?? this.limit
|
||||||
|
const cursor = pageSize * (page - 1)
|
||||||
|
const url = buildSearchUrl(str, cursor, pageSize, false)
|
||||||
|
const headers: Record<string, string> = { 'User-Agent': SEARCH_UA }
|
||||||
|
const { body } = await httpFetch(url, { method: 'get', headers, timeout: 15000 }).promise
|
||||||
|
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
|
||||||
|
return this.handleSearchBody(body, cursor, page)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
+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) {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
// https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
|
// https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
|
||||||
// ...?songmid=<songmid>
|
// ...?songmid=<songmid>
|
||||||
// 返回 Song(含 songmid);直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致)。
|
// 返回 Song(含 songmid);直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致)。
|
||||||
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'
|
||||||
import txApi from '../apis/tx.ts'
|
import txApi from '../apis/tx.ts'
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 搜索/分类复用现有 sdk/tx/songList.ts(handleParseId / getListId / getListDetail / getTag / getList)
|
// 搜索/分类复用现有 sdk/tx/songList.ts(handleParseId / getListId / getListDetail / getTag / getList)
|
||||||
// 推荐:musicu.fcg playlist.HotRecommendServer/get_hot_recommend(上游 GetRecommendedPlaylists)
|
// 推荐:musicu.fcg playlist.HotRecommendServer/get_hot_recommend(上游 GetRecommendedPlaylists)
|
||||||
// 链接解析:extractPlaylistId 为上游 extractQQPlaylistID 的 1:1 移植(含单测用例断言)
|
// 链接解析:extractPlaylistId 为上游 extractQQPlaylistID 的 1:1 移植(含单测用例断言)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayCount } from '../index.ts'
|
import { formatPlayCount } from '../index.ts'
|
||||||
import songList from './songList.ts'
|
import songList from './songList.ts'
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequestObject, 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 { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
||||||
import { formatSingerName } from '../utils.ts'
|
import { formatSingerName } from '../utils.ts'
|
||||||
|
|
||||||
@@ -120,7 +120,6 @@ export default {
|
|||||||
this._requestObj_list = httpFetch(
|
this._requestObj_list = httpFetch(
|
||||||
this.getListUrl(sortId, tagId, page),
|
this.getListUrl(sortId, tagId, page),
|
||||||
)
|
)
|
||||||
// console.log(this.getListUrl(sortId, tagId, page))
|
|
||||||
return this._requestObj_list!.promise.then(({ body }) => {
|
return this._requestObj_list!.promise.then(({ body }) => {
|
||||||
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
||||||
return tagId ? this.filterList2(body.playlist.data, page) : this.filterList(body.playlist.data, page)
|
return tagId ? this.filterList2(body.playlist.data, page) : this.filterList(body.playlist.data, page)
|
||||||
@@ -148,7 +147,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filterList2({ content }, page) {
|
filterList2({ content }, page) {
|
||||||
// console.log(content.v_item)
|
|
||||||
return {
|
return {
|
||||||
list: content.v_item.map(({ basic }) => ({
|
list: content.v_item.map(({ basic }) => ({
|
||||||
play_count: formatPlayCount(basic.play_cnt),
|
play_count: formatPlayCount(basic.play_cnt),
|
||||||
@@ -173,7 +171,6 @@ export default {
|
|||||||
|
|
||||||
const requestObj_listDetailLink = httpFetch(link)
|
const requestObj_listDetailLink = httpFetch(link)
|
||||||
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
|
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
|
||||||
// console.log(headers)
|
|
||||||
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
|
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
|
||||||
return location == null ? link : location
|
return location == null ? link : location
|
||||||
},
|
},
|
||||||
@@ -189,7 +186,6 @@ export default {
|
|||||||
if (!result) throw new Error('failed')
|
if (!result) throw new Error('failed')
|
||||||
}
|
}
|
||||||
id = result[1]
|
id = result[1]
|
||||||
// console.log(id)
|
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
@@ -225,7 +221,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filterListDetail(rawList) {
|
filterListDetail(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>> = {}
|
||||||
@@ -300,7 +295,6 @@ export default {
|
|||||||
})
|
})
|
||||||
.promise.then(({ body }) => {
|
.promise.then(({ body }) => {
|
||||||
if (body.code != 0) return this.search(text, page, limit, ++retryNum)
|
if (body.code != 0) return this.search(text, page, limit, ++retryNum)
|
||||||
// console.log(body.data.list)
|
|
||||||
return {
|
return {
|
||||||
list: body.data.list.map(item => {
|
list: body.data.list.map(item => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// 2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
|
// 2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
|
||||||
// 3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
|
// 3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
|
||||||
// 说明:虚拟条目的 id 为 profile:favorites(与上游一致),歌曲需凭 cookie 走同一接口拉取
|
// 说明:虚拟条目的 id 为 profile:favorites(与上游一致),歌曲需凭 cookie 走同一接口拉取
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayCount } from '../index.ts'
|
import { formatPlayCount } from '../index.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
import type { TxCredentials } from '../auth/store.ts'
|
import type { TxCredentials } from '../auth/store.ts'
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { HttpRequestObject } from '../../types.ts'
|
import { httpFetch } from '../../common/request.ts'
|
||||||
|
|
||||||
import { httpFetch } from '../../request.ts'
|
|
||||||
import { zzcSign } from './crypto.ts'
|
import { zzcSign } from './crypto.ts'
|
||||||
|
|
||||||
export const signRequest = async(data) => {
|
export const signRequest = async(data) => {
|
||||||
// console.log(data)
|
|
||||||
const sign = await zzcSign(JSON.stringify(data))
|
const sign = await zzcSign(JSON.stringify(data))
|
||||||
// console.log('sign', sign)
|
|
||||||
return httpFetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
|
return httpFetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -36,7 +32,7 @@ export const createMusicuFetch = async(data, retryNum = 0) => {
|
|||||||
},
|
},
|
||||||
}).promise
|
}).promise
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.warn(`[tx] musicu 请求失败将重试(${retryNum + 1}/3): ${(err as Error).message}`)
|
||||||
return createMusicuFetch(data, ++retryNum)
|
return createMusicuFetch(data, ++retryNum)
|
||||||
}
|
}
|
||||||
if (result.statusCode !== 200 || result.body.code != 0) return createMusicuFetch(data, ++retryNum)
|
if (result.statusCode !== 200 || result.body.code != 0) return createMusicuFetch(data, ++retryNum)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export const getHostIp = (hostname: string): CachedIp | undefined => {
|
|||||||
// family: 4,
|
// family: 4,
|
||||||
all: false,
|
all: false,
|
||||||
}, (err, address, family) => {
|
}, (err, address, family) => {
|
||||||
if (err) return console.log(err)
|
if (err) return console.warn(`[utils] DNS 预解析 ${hostname} 失败: ${err.message}`)
|
||||||
ipMap.set(hostname, { address, family })
|
ipMap.set(hostname, { address, family })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 搜索:cloudsearch pc type=10(eapi 通道,与现有 songList.search 同一通路)
|
// 搜索:cloudsearch pc type=10(eapi 通道,与现有 songList.search 同一通路)
|
||||||
// 详情/曲目:weapi/v1/album/<id>(匿名可用;登录 cookie 可选)
|
// 详情/曲目:weapi/v1/album/<id>(匿名可用;登录 cookie 可选)
|
||||||
// 链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
|
// 链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||||
import { formatSingerName } from '../utils.ts'
|
import { formatSingerName } from '../utils.ts'
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { dateFormat2 } from '../index.ts'
|
import { dateFormat2 } from '../index.ts'
|
||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
@@ -149,7 +149,6 @@ export default {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const { body, statusCode } = await _requestObj.promise
|
const { body, statusCode } = await _requestObj.promise
|
||||||
// console.log(body)
|
|
||||||
if (statusCode != 200 || body.code !== 200) throw new Error('获取评论失败')
|
if (statusCode != 200 || body.code !== 200) throw new Error('获取评论失败')
|
||||||
cursorTools.setCursor(songmid, body.data.cursor, cursorInfo.orderType, cursorInfo.offset, page)
|
cursorTools.setCursor(songmid, body.data.cursor, cursorInfo.orderType, cursorInfo.offset, page)
|
||||||
return { source: 'wy', comments: this.filterComment(body.data.comments), total: body.data.totalCount, page, limit, maxPage: Math.ceil(body.data.totalCount / limit) || 1 }
|
return { source: 'wy', comments: this.filterComment(body.data.comments), total: body.data.totalCount, page, limit, maxPage: Math.ceil(body.data.totalCount / limit) || 1 }
|
||||||
|
|||||||
+3
-24
@@ -1,5 +1,5 @@
|
|||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import musicDetailApi from './musicDetail.ts'
|
import musicDetailApi from './musicDetail.ts'
|
||||||
import type { HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
|
|
||||||
@@ -129,7 +129,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榜
|
||||||
@@ -142,24 +141,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 !== 200) return this.getBoards(retryNum)
|
|
||||||
// const list = this.filterBoardsData(response.body.list)
|
|
||||||
// console.log(list)
|
|
||||||
// console.log(JSON.stringify(list))
|
|
||||||
// this.list = list
|
|
||||||
// return {
|
|
||||||
// list,
|
|
||||||
// source: 'wy',
|
|
||||||
// }
|
|
||||||
this.list = topList
|
this.list = topList
|
||||||
return {
|
return {
|
||||||
list: topList,
|
list: topList,
|
||||||
@@ -168,7 +150,6 @@ export default {
|
|||||||
},
|
},
|
||||||
async getList(bangid, page, retryNum = 0) {
|
async getList(bangid, page, retryNum = 0) {
|
||||||
if (++retryNum > 6) return Promise.reject(new Error('try max num'))
|
if (++retryNum > 6) return Promise.reject(new Error('try max num'))
|
||||||
// console.log(bangid)
|
|
||||||
let resp
|
let resp
|
||||||
try {
|
try {
|
||||||
resp = await this.getData(bangid)
|
resp = await this.getData(bangid)
|
||||||
@@ -180,19 +161,17 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resp.statusCode !== 200 || resp.body.code !== 200) return this.getList(bangid, page, retryNum)
|
if (resp.statusCode !== 200 || resp.body.code !== 200) return this.getList(bangid, page, retryNum)
|
||||||
// console.log(resp.body)
|
|
||||||
let musicDetail
|
let musicDetail
|
||||||
try {
|
try {
|
||||||
musicDetail = await (musicDetailApi as any).getList(resp.body.playlist.trackIds.map(trackId => trackId.id))
|
musicDetail = await (musicDetailApi as any).getList(resp.body.playlist.trackIds.map(trackId => trackId.id))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log(err)
|
console.warn(`[wy] 榜单歌曲详情获取失败将重试(${retryNum + 1}): ${err.message}`)
|
||||||
if (err.message == 'try max num') {
|
if (err.message == 'try max num') {
|
||||||
throw err
|
throw err
|
||||||
} else {
|
} else {
|
||||||
return this.getList(bangid, page, retryNum)
|
return this.getList(bangid, page, retryNum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// console.log(musicDetail)
|
|
||||||
return {
|
return {
|
||||||
total: musicDetail.list.length,
|
total: musicDetail.list.length,
|
||||||
list: musicDetail.list,
|
list: musicDetail.list,
|
||||||
|
|||||||
+2
-75
@@ -1,39 +1,6 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { eapi } from './utils/crypto.ts'
|
import { eapi } from './utils/crypto.ts'
|
||||||
import type { LyricInfo, HttpRequestObject } from '../types.ts'
|
import type { HttpRequestObject } from '../types.ts'
|
||||||
// import { decodeName } from '../...ts'
|
|
||||||
|
|
||||||
// const parseLyric = (str, lrc) => {
|
|
||||||
// if (!str) return ''
|
|
||||||
|
|
||||||
// str = str.replace(/\r/g, '')
|
|
||||||
|
|
||||||
// let yrc = str.replace(/\[((\d+),\d+)\].*/g, str => {
|
|
||||||
// let result = str.match(/\[((\d+),\d+)\].*/)
|
|
||||||
// let time = parseInt(result[2])
|
|
||||||
// let ms = time % 1000
|
|
||||||
// time /= 1000
|
|
||||||
// let m = parseInt(time / 60).toString().padStart(2, '0')
|
|
||||||
// time %= 60
|
|
||||||
// let s = parseInt(time).toString().padStart(2, '0')
|
|
||||||
// time = `${m}:${s}.${ms}`
|
|
||||||
// str = str.replace(result[1], time)
|
|
||||||
|
|
||||||
// let startTime = 0
|
|
||||||
// str = str.replace(/\(0,1\) /g, ' ').replace(/\(\d+,\d+\)/g, time => {
|
|
||||||
// const [start, end] = time.replace(/^\((\d+,\d+)\)$/, '$1').split(',')
|
|
||||||
|
|
||||||
// time = `<${parseInt(startTime + parseInt(start))},${end}>`
|
|
||||||
// startTime = parseInt(startTime + parseInt(end))
|
|
||||||
// return time
|
|
||||||
// })
|
|
||||||
|
|
||||||
// return str
|
|
||||||
// })
|
|
||||||
|
|
||||||
// yrc = decodeName(yrc)
|
|
||||||
// return yrc.trim()
|
|
||||||
// }
|
|
||||||
|
|
||||||
const eapiRequest = (url: string, data: object) => {
|
const eapiRequest = (url: string, data: object) => {
|
||||||
return httpFetch('https://interface3.music.163.com/eapi/song/lyric/v1', {
|
return httpFetch('https://interface3.music.163.com/eapi/song/lyric/v1', {
|
||||||
@@ -41,18 +8,9 @@ const eapiRequest = (url: string, data: object) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
||||||
origin: 'https://music.163.com',
|
origin: 'https://music.163.com',
|
||||||
// cookie: 'os=pc; deviceId=A9C064BB4584D038B1565B58CB05F95290998EE8B025AA2D07AE; osver=Microsoft-Windows-10-Home-China-build-19043-64bit; appver=2.5.2.197409; channel=netease; MUSIC_A=37a11f2eb9de9930cad479b2ad495b0e4c982367fb6f909d9a3f18f876c6b49faddb3081250c4980dd7e19d4bd9bf384e004602712cf2b2b8efaafaab164268a00b47359f85f22705cc95cb6180f3aee40f5be1ebf3148d888aa2d90636647d0c3061cd18d77b7a0; __csrf=05b50d54082694f945d7de75c210ef94; mode=Z7M-KP5(7)GZ; NMTID=00OZLp2VVgq9QdwokUgq3XNfOddQyIAAAF_6i8eJg; ntes_kaola_ad=1',
|
|
||||||
},
|
},
|
||||||
form: eapi(url, data),
|
form: eapi(url, data),
|
||||||
})
|
})
|
||||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
|
||||||
// // console.log(raw)
|
|
||||||
// console.log(body)
|
|
||||||
// // console.log(eapiDecrypt(raw))
|
|
||||||
// // return eapiDecrypt(raw)
|
|
||||||
// return body
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseTools = {
|
const parseTools = {
|
||||||
@@ -218,35 +176,6 @@ const parseTools = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/pull/1523/files
|
|
||||||
// export default songmid => {
|
|
||||||
// const requestObj = httpFetch('https://music.163.com/api/linux/forward', {
|
|
||||||
// method: 'post',
|
|
||||||
// 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
|
|
||||||
// form: linuxapi({
|
|
||||||
// method: 'POST',
|
|
||||||
// url: 'https://music.163.com/api/song/lyric?_nmclfl=1',
|
|
||||||
// params: {
|
|
||||||
// id: songmid,
|
|
||||||
// tv: -1,
|
|
||||||
// lv: -1,
|
|
||||||
// rv: -1,
|
|
||||||
// kv: -1,
|
|
||||||
// },
|
|
||||||
// }),
|
|
||||||
// })
|
|
||||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
|
||||||
// if (body.code !== 200 || !body?.lrc?.lyric) return Promise.reject(new Error('Get lyric failed'))
|
|
||||||
// // console.log(body)
|
|
||||||
// return {
|
|
||||||
// lyric: body.lrc.lyric,
|
|
||||||
// tlyric: body.tlyric?.lyric ?? '',
|
|
||||||
// rlyric: body.romalrc?.lyric ?? '',
|
|
||||||
// // yrc: parseLyric(body.klyric.lyric),
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 参考社区通用的歌词时间标签修正思路
|
// 参考社区通用的歌词时间标签修正思路
|
||||||
const fixTimeLabel = (lrc: string, tlrc?: string, romalrc?: string) => {
|
const fixTimeLabel = (lrc: string, tlrc?: string, romalrc?: string) => {
|
||||||
@@ -277,11 +206,9 @@ export default (songmid: string): HttpRequestObject => {
|
|||||||
yrv: 0,
|
yrv: 0,
|
||||||
})
|
})
|
||||||
requestObj.promise = requestObj.promise.then(({ body }) => {
|
requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (body.code !== 200 || !body?.lrc?.lyric) return Promise.reject(new Error('Get lyric failed'))
|
if (body.code !== 200 || !body?.lrc?.lyric) return Promise.reject(new Error('Get lyric failed'))
|
||||||
const fixTimeLabelLrc = fixTimeLabel(body.lrc.lyric, body.tlyric?.lyric, body.romalrc?.lyric)
|
const fixTimeLabelLrc = fixTimeLabel(body.lrc.lyric, body.tlyric?.lyric, body.romalrc?.lyric)
|
||||||
const info = parseTools.parse(body.yrc?.lyric, body.ytlrc?.lyric, body.yromalrc?.lyric, fixTimeLabelLrc.lrc, fixTimeLabelLrc.tlrc, fixTimeLabelLrc.romalrc)
|
const info = parseTools.parse(body.yrc?.lyric, body.ytlrc?.lyric, body.yromalrc?.lyric, fixTimeLabelLrc.lrc, fixTimeLabelLrc.tlrc, fixTimeLabelLrc.romalrc)
|
||||||
// console.log(info)
|
|
||||||
if (!info.lyric) return Promise.reject(new Error('Get lyric failed'))
|
if (!info.lyric) return Promise.reject(new Error('Get lyric failed'))
|
||||||
return info
|
return info
|
||||||
}) as any
|
}) as any
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
|
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
|
||||||
@@ -12,7 +12,6 @@ export default {
|
|||||||
return arr.join('、')
|
return arr.join('、')
|
||||||
},
|
},
|
||||||
filterList({ songs, privileges }) {
|
filterList({ songs, privileges }) {
|
||||||
// console.log(songs, privileges)
|
|
||||||
const list: any[] = []
|
const list: any[] = []
|
||||||
songs.forEach((item, index) => {
|
songs.forEach((item, index) => {
|
||||||
const types: Array<{ type: string, size: string | null }> = []
|
const types: Array<{ type: string, size: string | null }> = []
|
||||||
@@ -87,7 +86,6 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// console.log(list)
|
|
||||||
return list
|
return list
|
||||||
},
|
},
|
||||||
async getList(ids = [], retryNum = 0) {
|
async getList(ids = [], retryNum = 0) {
|
||||||
@@ -106,7 +104,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)
|
|
||||||
return { source: 'wy', list: this.filterList(body) }
|
return { source: 'wy', list: this.filterList(body) }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
+1
-2
@@ -1,5 +1,5 @@
|
|||||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
|
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import type { RequestObj } from '../types.ts'
|
import type { RequestObj } from '../types.ts'
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ export default (songmid: string): RequestObj<any> => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
requestObj.promise = requestObj.promise.then(({ body }) => {
|
requestObj.promise = requestObj.promise.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (body.code !== 200 || !body.songs.length) return Promise.reject(new Error('获取歌曲信息失败'))
|
if (body.code !== 200 || !body.songs.length) return Promise.reject(new Error('获取歌曲信息失败'))
|
||||||
return body.songs[0]
|
return body.songs[0]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// import { httpFetch } from '../request.ts'
|
// import { httpFetch } from '../common/request.ts'
|
||||||
// import { weapi } from './utils/crypto.ts'
|
// import { weapi } from './utils/crypto.ts'
|
||||||
import { sizeFormate, formatPlayTime } from '../index.ts'
|
import { sizeFormate, formatPlayTime } from '../index.ts'
|
||||||
// import musicDetailApi from './musicDetail.ts'
|
// import musicDetailApi from './musicDetail.ts'
|
||||||
@@ -36,7 +36,6 @@ export default {
|
|||||||
return arr.join('、')
|
return arr.join('、')
|
||||||
},
|
},
|
||||||
handleResult(rawList) {
|
handleResult(rawList) {
|
||||||
// console.log(rawList)
|
|
||||||
if (!rawList) return []
|
if (!rawList) return []
|
||||||
return rawList.map(item => {
|
return rawList.map(item => {
|
||||||
item = item.baseInfo.simpleSongData
|
item = item.baseInfo.simpleSongData
|
||||||
@@ -95,10 +94,8 @@ export default {
|
|||||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||||
if (limit == null) limit = this.limit
|
if (limit == null) limit = this.limit
|
||||||
return this.musicSearch(str, page, limit).then(result => {
|
return this.musicSearch(str, page, limit).then(result => {
|
||||||
// console.log(result)
|
|
||||||
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
|
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
|
||||||
let list = this.handleResult(result.data.resources || [])
|
let list = this.handleResult(result.data.resources || [])
|
||||||
// console.log(list)
|
|
||||||
|
|
||||||
if (list == null) return this.search(str, page, limit, retryNum)
|
if (list == null) return this.search(str, page, limit, retryNum)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// 裸数字 id 也接受(补充便利;上游仅接受链接)
|
// 裸数字 id 也接受(补充便利;上游仅接受链接)
|
||||||
// 直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
|
// 直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
|
||||||
// 歌单/专辑链接抛清晰错误,提示使用对应 parsePlaylist/parseAlbum。
|
// 歌单/专辑链接抛清晰错误,提示使用对应 parsePlaylist/parseAlbum。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { getWyCookie } from '../auth/store.ts'
|
import { getWyCookie } from '../auth/store.ts'
|
||||||
import wyApi from '../apis/wy.ts'
|
import wyApi from '../apis/wy.ts'
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
// 推荐:weapi/personalized/playlist(上游 GetRecommendedPlaylists,匿名可用)
|
// 推荐:weapi/personalized/playlist(上游 GetRecommendedPlaylists,匿名可用)
|
||||||
// 详情/曲目:复用 songList.getListDetail(linuxapi forward),按 page/limit 切片
|
// 详情/曲目:复用 songList.getListDetail(linuxapi forward),按 page/limit 切片
|
||||||
// 链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id(上游 parseNeteaseLink)
|
// 链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id(上游 parseNeteaseLink)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { formatPlayCount } from '../index.ts'
|
import { formatPlayCount } from '../index.ts'
|
||||||
import { getWyCookie } from '../auth/store.ts'
|
import { getWyCookie } from '../auth/store.ts'
|
||||||
|
|||||||
+2
-12
@@ -4,7 +4,7 @@
|
|||||||
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/playlist_detail.js
|
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/playlist_detail.js
|
||||||
|
|
||||||
import { weapi, linuxapi } from './utils/crypto.ts'
|
import { weapi, linuxapi } from './utils/crypto.ts'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
import { formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
|
||||||
import musicDetailApi from './musicDetail.ts'
|
import musicDetailApi from './musicDetail.ts'
|
||||||
import { eapiRequest } from './utils/index.ts'
|
import { eapiRequest } from './utils/index.ts'
|
||||||
@@ -39,7 +39,6 @@ export default {
|
|||||||
|
|
||||||
const requestObj_listDetailLink = httpFetch(link)
|
const requestObj_listDetailLink = httpFetch(link)
|
||||||
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
|
const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise
|
||||||
// console.log(headers)
|
|
||||||
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
|
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
|
||||||
const url = location == null ? link : location
|
const url = location == null ? link : location
|
||||||
return this.regExps.listDetailLink.test(url)
|
return this.regExps.listDetailLink.test(url)
|
||||||
@@ -62,7 +61,6 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
id = await this.handleParseId(id)
|
id = await this.handleParseId(id)
|
||||||
}
|
}
|
||||||
// console.log(id)
|
|
||||||
}
|
}
|
||||||
return { id, cookie }
|
return { id, cookie }
|
||||||
},
|
},
|
||||||
@@ -92,7 +90,6 @@ export default {
|
|||||||
if (statusCode !== 200 || body.code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
|
if (statusCode !== 200 || body.code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
|
||||||
let limit = 1000
|
let limit = 1000
|
||||||
let rangeStart = (page - 1) * limit
|
let rangeStart = (page - 1) * limit
|
||||||
// console.log(body)
|
|
||||||
let list
|
let list
|
||||||
if (body.playlist.trackIds.length == body.privileges.length) {
|
if (body.playlist.trackIds.length == body.privileges.length) {
|
||||||
list = this.filterListDetail(body)
|
list = this.filterListDetail(body)
|
||||||
@@ -100,7 +97,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
list = (await (musicDetailApi as any).getList(body.playlist.trackIds.slice(rangeStart, limit * page).map(trackId => trackId.id))).list
|
list = (await (musicDetailApi as any).getList(body.playlist.trackIds.slice(rangeStart, limit * page).map(trackId => trackId.id))).list
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.log(err)
|
console.warn(`[wy] 歌单歌曲详情获取失败将重试(${tryNum + 1}): ${err.message}`)
|
||||||
if (err.message == 'try max num') {
|
if (err.message == 'try max num') {
|
||||||
throw err
|
throw err
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +105,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// console.log(list)
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
page,
|
page,
|
||||||
@@ -125,7 +121,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filterListDetail({ playlist: { tracks }, privileges }) {
|
filterListDetail({ playlist: { tracks }, privileges }) {
|
||||||
// console.log(tracks, privileges)
|
|
||||||
const list: any[] = []
|
const list: any[] = []
|
||||||
tracks.forEach((item, index) => {
|
tracks.forEach((item, index) => {
|
||||||
const types: Array<{ type: string, size: string | null }> = []
|
const types: Array<{ type: string, size: string | null }> = []
|
||||||
@@ -218,7 +213,6 @@ export default {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
return this._requestObj_list!.promise.then(({ body }) => {
|
return this._requestObj_list!.promise.then(({ body }) => {
|
||||||
// console.log(body)
|
|
||||||
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
|
||||||
return {
|
return {
|
||||||
list: this.filterList(body.playlists),
|
list: this.filterList(body.playlists),
|
||||||
@@ -230,7 +224,6 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
filterList(rawData) {
|
filterList(rawData) {
|
||||||
// console.log(rawData)
|
|
||||||
return rawData.map(item => ({
|
return rawData.map(item => ({
|
||||||
play_count: formatPlayCount(item.playCount),
|
play_count: formatPlayCount(item.playCount),
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
@@ -254,7 +247,6 @@ export default {
|
|||||||
form: weapi({}),
|
form: weapi({}),
|
||||||
})
|
})
|
||||||
return this._requestObj_tags!.promise.then(({ body }) => {
|
return this._requestObj_tags!.promise.then(({ body }) => {
|
||||||
// console.log(JSON.stringify(body))
|
|
||||||
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
||||||
return this.filterTagInfo(body)
|
return this.filterTagInfo(body)
|
||||||
})
|
})
|
||||||
@@ -292,7 +284,6 @@ export default {
|
|||||||
form: weapi({}),
|
form: weapi({}),
|
||||||
})
|
})
|
||||||
return this._requestObj_hotTags!.promise.then(({ body }) => {
|
return this._requestObj_hotTags!.promise.then(({ body }) => {
|
||||||
// console.log(JSON.stringify(body))
|
|
||||||
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
if (body.code !== this.successCode) return this.getTag(++tryNum)
|
||||||
return this.filterHotTagInfo(body.tags)
|
return this.filterHotTagInfo(body.tags)
|
||||||
})
|
})
|
||||||
@@ -324,7 +315,6 @@ export default {
|
|||||||
})
|
})
|
||||||
.promise.then(({ body }) => {
|
.promise.then(({ body }) => {
|
||||||
if (body.code != this.successCode) throw new Error('filed')
|
if (body.code != this.successCode) throw new Error('filed')
|
||||||
// console.log(body)
|
|
||||||
return {
|
return {
|
||||||
list: this.filterList(body.result.playlists),
|
list: this.filterList(body.result.playlists),
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 未登录抛出清晰错误,不返回空列表
|
// 未登录抛出清晰错误,不返回空列表
|
||||||
// 1) weapi/nuser/account/get → 当前用户 userId
|
// 1) weapi/nuser/account/get → 当前用户 userId
|
||||||
// 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
|
// 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { weapi } from './utils/crypto.ts'
|
import { weapi } from './utils/crypto.ts'
|
||||||
import { formatPlayCount } from '../index.ts'
|
import { formatPlayCount } from '../index.ts'
|
||||||
import { getCredentials, getWyCookie } from '../auth/store.ts'
|
import { getCredentials, getWyCookie } from '../auth/store.ts'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { httpFetch } from '../../request.ts'
|
import { httpFetch } from '../../common/request.ts'
|
||||||
import { eapi } from './crypto.ts'
|
import { eapi } from './crypto.ts'
|
||||||
import { getWyCookie } from '../../auth/store.ts'
|
import { getWyCookie } from '../../auth/store.ts'
|
||||||
import type { HttpRequestObject } from '../../types.ts'
|
import type { HttpRequestObject } from '../../types.ts'
|
||||||
@@ -15,12 +15,4 @@ export const eapiRequest = (url: string, data: object): HttpRequestObject => {
|
|||||||
},
|
},
|
||||||
form: eapi(url, data),
|
form: eapi(url, data),
|
||||||
})
|
})
|
||||||
// requestObj.promise = requestObj.promise.then(({ body }) => {
|
|
||||||
// // console.log(raw)
|
|
||||||
// console.log(body)
|
|
||||||
// // console.log(eapiDecrypt(raw))
|
|
||||||
// // return eapiDecrypt(raw)
|
|
||||||
// return body
|
|
||||||
// })
|
|
||||||
// return requestObj
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 接口: https://www.ximalaya.com/revision/album/getTracksList?albumId=&pageNum=&sort=0
|
// 接口: https://www.ximalaya.com/revision/album/getTracksList?albumId=&pageNum=&sort=0
|
||||||
// 实测(2026-10-02)匿名可翻全部页(固定每页 30 条);仅曲目元信息(trackId/title/isPaid),
|
// 实测(2026-10-02)匿名可翻全部页(固定每页 30 条);仅曲目元信息(trackId/title/isPaid),
|
||||||
// 播放地址需另走 baseInfo。注意 v1 版接口(/revision/album/v1/getTracksList)需要 webtk 签名且有风控。
|
// 播放地址需另走 baseInfo。注意 v1 版接口(/revision/album/v1/getTracksList)需要 webtk 签名且有风控。
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { XM_UA } from './sign.ts'
|
import { XM_UA } from './sign.ts'
|
||||||
|
|
||||||
const TRACKS_URL = 'https://www.ximalaya.com/revision/album/getTracksList'
|
const TRACKS_URL = 'https://www.ximalaya.com/revision/album/getTracksList'
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
// 响应 trackInfo 顶层挂载(playUrlList/title/cover*/duration/isPaid/isAuthorized/isXimiUhqTrack),
|
// 响应 trackInfo 顶层挂载(playUrlList/title/cover*/duration/isPaid/isAuthorized/isXimiUhqTrack),
|
||||||
// playUrlList[].url 为 AES-ECB 加密地址(key 取自官方客户端 4.0.14 asar),见 decoder.ts
|
// playUrlList[].url 为 AES-ECB 加密地址(key 取自官方客户端 4.0.14 asar),见 decoder.ts
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { makeXmSign, XM_UA } from './sign.ts'
|
import { makeXmSign, XM_UA } from './sign.ts'
|
||||||
import { getCredentials } from '../auth/store.ts'
|
import { getCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// risk invalid 风控,本端点 + condition=relation&device=web 不受影响)
|
// risk invalid 风控,本端点 + condition=relation&device=web 不受影响)
|
||||||
// 返回 data.result.response.docs[],字段 id/title/duration(秒)/nickname/album_id/album_title/
|
// 返回 data.result.response.docs[],字段 id/title/duration(秒)/nickname/album_id/album_title/
|
||||||
// album_cover_path/is_paid/is_trailer
|
// album_cover_path/is_paid/is_trailer
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { formatPlayTime } from '../index.ts'
|
import { formatPlayTime } from '../index.ts'
|
||||||
import { XM_UA } from './sign.ts'
|
import { XM_UA } from './sign.ts'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import zlib from 'node:zlib'
|
import zlib from 'node:zlib'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { httpFetch } from '../request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { DEVICE_INFO } from './deviceInfo.ts'
|
import { DEVICE_INFO } from './deviceInfo.ts'
|
||||||
|
|
||||||
const SIGN_KEY = 'm9ZtRrz:qujT8@da'
|
const SIGN_KEY = 'm9ZtRrz:qujT8@da'
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// 汽水音乐外部签名服务 —— 在 Linux 上用 Wine 跑官方 bdms.node,对外只提供签名
|
|
||||||
//
|
|
||||||
// 为什么需要它:bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node/Electron 加载。
|
|
||||||
// 把「加载官方模块」这件事单独关进一个容器(Wine + Windows Node),主服务就能跑在任何平台,
|
|
||||||
// 通过 HTTP 拿 X-Helios / X-Medusa —— 契约与 sodahub-org/libmssdk、libresoda、
|
|
||||||
// Meting-API 的远程签名服务互通(三者 JSON 可互换)。
|
|
||||||
//
|
|
||||||
// 路由:
|
|
||||||
// GET /healthz → { ok, service, version, bdmsAvailable, deviceId(脱敏), auth, platform }
|
|
||||||
// GET /config → 需鉴权:设备身份(调用方据此对齐 URL 里的 device_id/iid)
|
|
||||||
// POST /sign → { url, method?, headers, body?, device_id? } → { ok, headers:{X-Helios,X-Medusa}, device_id, elapsed_ms }
|
|
||||||
// POST /selftest → 用样例请求自检(不联网)
|
|
||||||
//
|
|
||||||
// 鉴权:QS_SIGNER_TOKEN(Authorization: Bearer <token>)。未设 token 时除非
|
|
||||||
// QS_SIGNER_ALLOW_ANONYMOUS=1,否则拒绝启动 —— 签名能力等价于账号取流能力,别裸奔。
|
|
||||||
// 环境变量:QS_SIGNER_BIND(127.0.0.1) / QS_SIGNER_PORT(8899) / QS_SIGNER_TOKEN /
|
|
||||||
// QS_SIGNER_DEVICE_ID / QS_SIGNER_BDMS_MODULE(默认 ./vendor/bdms.node) / QS_SIGNER_MAX_BODY
|
|
||||||
// 日志只记请求形状与耗时:Cookie、X-Helios、X-Medusa 一律不落盘。
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
const http = require('node:http')
|
|
||||||
const crypto = require('node:crypto')
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const path = require('node:path')
|
|
||||||
|
|
||||||
const VERSION = '0.1.0'
|
|
||||||
const SERVICE = 'qs-signer'
|
|
||||||
const BIND = process.env.QS_SIGNER_BIND || '127.0.0.1'
|
|
||||||
const PORT = Number(process.env.QS_SIGNER_PORT) || 8899
|
|
||||||
const TOKEN = process.env.QS_SIGNER_TOKEN || ''
|
|
||||||
const ALLOW_ANONYMOUS = process.env.QS_SIGNER_ALLOW_ANONYMOUS === '1'
|
|
||||||
const DEFAULT_DEVICE_ID = process.env.QS_SIGNER_DEVICE_ID || ''
|
|
||||||
const MAX_BODY = Number(process.env.QS_SIGNER_MAX_BODY) || 256 * 1024
|
|
||||||
const MODULE_PATH = path.resolve(process.env.QS_SIGNER_BDMS_MODULE || path.join(__dirname, 'vendor', 'bdms.node'))
|
|
||||||
|
|
||||||
if (!TOKEN && !ALLOW_ANONYMOUS) {
|
|
||||||
console.error('[qs-signer] 拒绝启动:未设置 QS_SIGNER_TOKEN(本机调试可显式 QS_SIGNER_ALLOW_ANONYMOUS=1)')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maskDevice = (id) => {
|
|
||||||
const value = String(id || '')
|
|
||||||
return value.length <= 6 ? value : `${value.slice(0, 3)}***${value.slice(-3)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 官方模块加载:一次 require,按设备 init(换设备要重新 init)——
|
|
||||||
let bdms = null
|
|
||||||
let loadError = ''
|
|
||||||
let initedDevice = ''
|
|
||||||
const loadBdms = () => {
|
|
||||||
if (bdms) return bdms
|
|
||||||
if (loadError) return null
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(MODULE_PATH)) throw new Error(`找不到 ${MODULE_PATH}`)
|
|
||||||
const mod = require(MODULE_PATH)
|
|
||||||
if (typeof mod.init !== 'function' || typeof mod.generateHttpSignatureHeaders !== 'function') {
|
|
||||||
throw new Error('导出不符合预期(需要 init / generateHttpSignatureHeaders)')
|
|
||||||
}
|
|
||||||
bdms = mod
|
|
||||||
console.log(`[qs-signer] bdms 模块已加载: ${MODULE_PATH}`)
|
|
||||||
return bdms
|
|
||||||
} catch (err) {
|
|
||||||
loadError = String((err && err.message) || err)
|
|
||||||
console.error(`[qs-signer] bdms 模块加载失败: ${loadError}(/sign 将返回 503,/healthz 仍可用)`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceIdFromRequest = (url, headers, explicit) => {
|
|
||||||
if (explicit) return String(explicit).trim()
|
|
||||||
try {
|
|
||||||
const parsed = new URL(String(url))
|
|
||||||
for (const key of ['device_id', 'did', 'fp']) {
|
|
||||||
const value = parsed.searchParams.get(key)
|
|
||||||
if (value) return value.trim()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// URL 不合法就继续往下找
|
|
||||||
}
|
|
||||||
for (const name of ['x-device-id', 'device-id', 'device_id']) {
|
|
||||||
if (headers && headers[name]) return String(headers[name]).trim()
|
|
||||||
}
|
|
||||||
return DEFAULT_DEVICE_ID
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureInit = (deviceId) => {
|
|
||||||
const mod = loadBdms()
|
|
||||||
if (!mod) throw new Error(loadError || 'bdms 模块不可用')
|
|
||||||
const target = String(deviceId || DEFAULT_DEVICE_ID || '').trim()
|
|
||||||
if (!target) throw new Error('缺少 device_id:请求里带 device_id(或 URL 查询参数 device_id/did/fp),或设置 QS_SIGNER_DEVICE_ID')
|
|
||||||
if (initedDevice !== target) {
|
|
||||||
mod.init({ deviceId: target })
|
|
||||||
initedDevice = target
|
|
||||||
console.log(`[qs-signer] 已绑定设备 ${maskDevice(target)}`)
|
|
||||||
}
|
|
||||||
return mod
|
|
||||||
}
|
|
||||||
|
|
||||||
const flattenHeaders = (headers) => {
|
|
||||||
const lines = []
|
|
||||||
for (const [name, value] of Object.entries(headers || {})) {
|
|
||||||
if (!name || value === undefined || value === null) continue
|
|
||||||
const items = Array.isArray(value) ? value : [value]
|
|
||||||
for (const item of items) lines.push(`${name}\r\n${item}`)
|
|
||||||
}
|
|
||||||
return lines.join('\r\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseSignaturePairs = (raw) => {
|
|
||||||
const fields = String(raw || '').split('\r\n').filter((t) => t.trim())
|
|
||||||
const signed = {}
|
|
||||||
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
|
|
||||||
return signed
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 签名一次请求:返回需追加/覆盖的请求头(含服务端补的 x-ss-stub) */
|
|
||||||
const signRequest = ({ url, headers = {}, body, deviceId }) => {
|
|
||||||
if (!url) throw new Error('url 不能为空')
|
|
||||||
const target = ensureInit(deviceIdFromRequest(url, headers, deviceId))
|
|
||||||
const outgoing = { ...headers }
|
|
||||||
// 有 body 却没给 x-ss-stub 时补上(它是签名输入,也要随请求发出),大小写保持官方写法
|
|
||||||
if (body && !Object.keys(outgoing).some((k) => k.toLowerCase() === 'x-ss-stub')) {
|
|
||||||
outgoing['x-ss-stub'] = crypto.createHash('md5').update(String(body)).digest('hex').toUpperCase()
|
|
||||||
}
|
|
||||||
const signed = parseSignaturePairs(target.generateHttpSignatureHeaders(String(url), flattenHeaders(outgoing)))
|
|
||||||
if (!signed['X-Helios'] && !signed['x-helios']) throw new Error('签名结果缺少 X-Helios')
|
|
||||||
return { signed: { ...signed, ...(body ? { 'x-ss-stub': outgoing['x-ss-stub'] } : {}) }, deviceId: initedDevice }
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (res, status, payload) => {
|
|
||||||
const text = JSON.stringify(payload)
|
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) })
|
|
||||||
res.end(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
const readBody = (req) => new Promise((resolve, reject) => {
|
|
||||||
let size = 0
|
|
||||||
const chunks = []
|
|
||||||
req.on('data', (chunk) => {
|
|
||||||
size += chunk.length
|
|
||||||
if (size > MAX_BODY) {
|
|
||||||
reject(Object.assign(new Error('请求体过大'), { status: 413 }))
|
|
||||||
req.destroy()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chunks.push(chunk)
|
|
||||||
})
|
|
||||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
||||||
req.on('error', reject)
|
|
||||||
})
|
|
||||||
|
|
||||||
const authorized = (req) => {
|
|
||||||
if (!TOKEN) return true
|
|
||||||
const header = String(req.headers.authorization || '')
|
|
||||||
const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : ''
|
|
||||||
const alt = String(req.headers['x-signer-token'] || '')
|
|
||||||
return bearer === TOKEN || alt === TOKEN
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
const started = Date.now()
|
|
||||||
const url = String(req.url || '')
|
|
||||||
const pathname = url.split('?')[0]
|
|
||||||
|
|
||||||
const log = (status) => console.log(`[qs-signer] ${req.method} ${pathname} → ${status} (${Date.now() - started}ms)`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (pathname === '/healthz') {
|
|
||||||
log(200)
|
|
||||||
return json(res, 200, {
|
|
||||||
ok: true,
|
|
||||||
service: SERVICE,
|
|
||||||
version: VERSION,
|
|
||||||
bdmsAvailable: Boolean(bdms) || (!loadError && fs.existsSync(MODULE_PATH)),
|
|
||||||
deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID),
|
|
||||||
auth: Boolean(TOKEN),
|
|
||||||
platform: `${process.platform} ${process.arch}`,
|
|
||||||
node: process.version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authorized(req)) {
|
|
||||||
log(401)
|
|
||||||
return json(res, 401, { ok: false, error: 'unauthorized', code: 'unauthorized' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/config') {
|
|
||||||
log(200)
|
|
||||||
return json(res, 200, {
|
|
||||||
ok: true,
|
|
||||||
service: SERVICE,
|
|
||||||
version: VERSION,
|
|
||||||
bdmsModule: MODULE_PATH,
|
|
||||||
bdmsLoaded: Boolean(bdms),
|
|
||||||
loadError,
|
|
||||||
deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID),
|
|
||||||
identity: { device_id: initedDevice || DEFAULT_DEVICE_ID, source: initedDevice ? 'request' : (DEFAULT_DEVICE_ID ? 'env' : 'unset') },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/selftest') {
|
|
||||||
try {
|
|
||||||
const sampleUrl = 'https://api.qishui.com/luna/pc/track_v2?aid=386088&device_id=1&iid=2'
|
|
||||||
const { signed } = signRequest({ url: sampleUrl, headers: { 'user-agent': 'LunaPC/selftest' }, deviceId: DEFAULT_DEVICE_ID || '1' })
|
|
||||||
log(200)
|
|
||||||
return json(res, 200, { ok: true, headers: Object.keys(signed), lengths: Object.fromEntries(Object.entries(signed).map(([k, v]) => [k, String(v).length])) })
|
|
||||||
} catch (err) {
|
|
||||||
log(503)
|
|
||||||
return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/sign' && req.method === 'POST') {
|
|
||||||
const raw = await readBody(req)
|
|
||||||
let payload
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(raw || '{}')
|
|
||||||
} catch {
|
|
||||||
log(400)
|
|
||||||
return json(res, 400, { ok: false, error: 'invalid JSON', code: 'bad_request' })
|
|
||||||
}
|
|
||||||
const { url: target, headers = {}, body = '', device_id: deviceId } = payload
|
|
||||||
try {
|
|
||||||
const { signed } = signRequest({ url: target, headers, body, deviceId })
|
|
||||||
log(200)
|
|
||||||
// headers 嵌套给标准调用方;顶层扁平给 Meting 风格调用方
|
|
||||||
return json(res, 200, { ok: true, headers: signed, ...signed, device_id: initedDevice, elapsed_ms: Date.now() - started })
|
|
||||||
} catch (err) {
|
|
||||||
log(503)
|
|
||||||
return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(404)
|
|
||||||
return json(res, 404, { ok: false, error: 'not found' })
|
|
||||||
} catch (err) {
|
|
||||||
const status = err && err.status ? err.status : 500
|
|
||||||
log(status)
|
|
||||||
return json(res, status, { ok: false, error: String((err && err.message) || err) })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
server.listen(PORT, BIND, () => {
|
|
||||||
console.log(`[qs-signer] 监听 http://${BIND}:${PORT}(鉴权: ${TOKEN ? 'Bearer token' : '匿名(仅调试)'})`)
|
|
||||||
loadBdms()
|
|
||||||
})
|
|
||||||
+1
-1
@@ -431,7 +431,7 @@ export const loginRoutes = async(fastify) => {
|
|||||||
description: '用 B站 App 扫码后轮询 /login/bl/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。',
|
description: '用 B站 App 扫码后轮询 /login/bl/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。',
|
||||||
tags: ['登录'],
|
tags: ['登录'],
|
||||||
},
|
},
|
||||||
}, async(request, reply) => {
|
}, async(_request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await biliAuth.createQRLogin()
|
return await biliAuth.createQRLogin()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# 专有二进制与构建产物不入库
|
|
||||||
metasecml.dll
|
|
||||||
*.exe
|
|
||||||
*.log
|
|
||||||
*.obj
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# mssdk-sign —— 汽水音乐 metasec 签名 HTTP 服务
|
|
||||||
|
|
||||||
把汽水 PC 客户端自带的 `metasecml.dll`(字节 metasec,官方签名主体 *Douyin Vision Co., Ltd.*)
|
|
||||||
关在一个进程里,对外只暴露一个 HTTP `/sign`。于是:
|
|
||||||
|
|
||||||
- **Windows**:直接跑。
|
|
||||||
- **Linux**:丢进 **Wine** 跑,宿主机的服务(MusicServer 等)用 HTTP 调它 —— 专有 DLL 不出 Wine 前缀,宿主机不需要 Windows 运行时。
|
|
||||||
|
|
||||||
对应地,MusicServer 侧 `sdk/qs/mssdk.ts` 里那套「进程内 require bdms.node」可以换成「HTTP 调本服务」,
|
|
||||||
跨平台部署就不用装官方客户端了(二进制仍需自己从官方客户端里取,不入库、不再分发)。
|
|
||||||
|
|
||||||
## 文件
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `mssdk-sign.cpp` | 全部实现(单文件,无第三方依赖:Win32 + Winsock + 自带 MD5/JSON) |
|
|
||||||
| `build.bat` | Windows/MSVC 构建 |
|
|
||||||
| `build-mingw.sh` | Linux 交叉编译成 exe(给 Wine 跑) |
|
|
||||||
| `client.mjs` | Node 客户端示例(可直接 `node client.mjs --send` 验证) |
|
|
||||||
| `REVERSE.md` | **逆向记录**:DLL 身份/壳/真实调用 ABI/行为规则/升级后如何重新定位偏移 |
|
|
||||||
|
|
||||||
## 构建
|
|
||||||
|
|
||||||
Windows(MSVC,需要 VS BuildTools):
|
|
||||||
|
|
||||||
```bat
|
|
||||||
build.bat
|
|
||||||
:: 生成 mssdk-sign.exe
|
|
||||||
mssdk-sign.exe --selftest --dll .\metasecml.dll
|
|
||||||
```
|
|
||||||
|
|
||||||
Linux → Wine(MinGW-w64 交叉编译,静态链接,不依赖 libstdc++/winpthread DLL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install mingw-w64
|
|
||||||
./build-mingw.sh # 生成 mssdk-sign.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:源码含中文注释/字符串,**MSVC 必须带 `/utf-8`**(`build.bat` 已带),否则会报一堆莫名其妙的语法错误。
|
|
||||||
|
|
||||||
## 运行
|
|
||||||
|
|
||||||
Windows:
|
|
||||||
|
|
||||||
```bat
|
|
||||||
mssdk-sign.exe --port 16889 --dll .\metasecml.dll --token 你的口令 -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Wine(建议放在自己的 prefix 里,和 DLL 同目录):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /opt/mssdk-sign && cd /opt/mssdk-sign
|
|
||||||
cp mssdk-sign.exe metasecml.dll .
|
|
||||||
WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --selftest --dll ./metasecml.dll # 先自检
|
|
||||||
WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --port 16889 --bind 0.0.0.0 --token 你的口令 -v
|
|
||||||
```
|
|
||||||
|
|
||||||
systemd 单元示例:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=mssdk-sign (metasec signer in Wine)
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Environment=WINEPREFIX=/opt/wine-mssdk
|
|
||||||
Environment=WINEDEBUG=-all
|
|
||||||
WorkingDirectory=/opt/mssdk-sign
|
|
||||||
ExecStart=/usr/bin/wine /opt/mssdk-sign/mssdk-sign.exe --port 16889 --bind 127.0.0.1 --token CHANGE_ME
|
|
||||||
Restart=always
|
|
||||||
RestartSec=3
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
参数:
|
|
||||||
|
|
||||||
| 参数 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `--port <n>` | 监听端口,默认 16889 |
|
|
||||||
| `--bind <addr>` | 默认 `127.0.0.1`;容器/跨机调用才用 `0.0.0.0`(务必配 token) |
|
|
||||||
| `--dll <path>` | `metasecml.dll` 路径,默认 exe 同目录 |
|
|
||||||
| `--token <t>` | 启用鉴权(`Authorization: Bearer <t>` 或 `X-Auth-Token: <t>`) |
|
|
||||||
| `--sign-rva <hex>` | 手动指定签名函数偏移(默认按 DLL 版本表,见下) |
|
|
||||||
| `--selftest` | 只做「加载 DLL + 签名自检」后退出(部署前必跑) |
|
|
||||||
| `-v` | 打印每个请求的签名耗时 |
|
|
||||||
|
|
||||||
## 接口
|
|
||||||
|
|
||||||
### `GET /healthz`(免鉴权)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"ok":true,"service":"mssdk-sign","version":"0.1.0",
|
|
||||||
"signer":"D:/MusicServer/soda-mssdk/metasecml.dll",
|
|
||||||
"dllVersion":"4.6.1.0","signRva":"0x52af0","auth":true}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `POST /sign`
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"url": "https://api.qishui.com/luna/pc/track_v2?aid=386088&…", // 必填,带完整 query
|
|
||||||
"body": "{\"track_id\":\"…\"}", // 可选:给了且没带 x-ss-stub 时,服务端自动补
|
|
||||||
"headers": { // 必填:真实要发出去的那一份
|
|
||||||
"content-type": "application/json; charset=utf-8",
|
|
||||||
"user-agent": "LunaPC/3.7.0(452316191)",
|
|
||||||
"x-luna-background-type": "foreground",
|
|
||||||
"x-tt-trace-id": "00-…-…-01"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"headers": {"X-Helios":"…","X-Medusa":"…","X-Neptune":"…"},
|
|
||||||
"added": {"x-ss-stub":"DBA20AB94302CB03AC25A216DD953258"},
|
|
||||||
"elapsed_ms": 43
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
失败时:`{"ok":false,"error":"…"}` + 400(入参错)/ 401(token)/ 500(签名失败)。
|
|
||||||
|
|
||||||
### 三条硬性约定(踩过坑)
|
|
||||||
|
|
||||||
1. **`headers` 必须是真实要发出的那一份**,一个都不能少(尤其 `cookie`、`x-ss-stub`)。签名覆盖 URL + 全部请求头,
|
|
||||||
少一个服务端就回 `HTTP 200 + 空 body`。
|
|
||||||
2. **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也要随请求发出。本服务在你传了 `body` 时会自动算并回传
|
|
||||||
(`added.x-ss-stub`),记得把它一起发出去。
|
|
||||||
3. **设备要成套**:URL 里的 `device_id` / `iid` / `fp` 必须是你自己的设备;签名和 URL 里的 `device_id` 绑死,
|
|
||||||
改了 URL 设备号 → 服务端 `200 + 空 body`(实测)。
|
|
||||||
|
|
||||||
### 客户端(Node)
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { signRequest } from './client.mjs'
|
|
||||||
|
|
||||||
const { headers, added } = await signRequest(url, headersToSend, body)
|
|
||||||
await fetch(url, { method: 'POST', headers: { ...headersToSend, ...headers, ...added }, body })
|
|
||||||
```
|
|
||||||
|
|
||||||
集成到 MusicServer 时,把 `sdk/qs/mssdk.ts` 的 `signQishuiRequest()` 换成上面这次 HTTP 调用即可
|
|
||||||
(签名覆盖的 headers 与请求实际发出的 headers 保持同一份、同一顺序)。
|
|
||||||
|
|
||||||
自检:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node client.mjs # healthz + 签一次(不发真实请求)
|
|
||||||
node client.mjs --send # 顺带发给 api.qishui.com 验证签名被接受
|
|
||||||
```
|
|
||||||
|
|
||||||
## 它是怎么调 DLL 的(逆向结论,2026-10-02)
|
|
||||||
|
|
||||||
- 导出表里的 `MSBridgeML` / `MSBridgeOV` / `o0o0` 都是幌子(桩 / 线程状态初始化),
|
|
||||||
**真正的签名函数在 `image base + 0x52af0`**,原型:
|
|
||||||
|
|
||||||
```c
|
|
||||||
char* sign(const char* url, const char* headers_lines); // headers: "名\r\n值\r\n…"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 返回 `"名\r\n值\r\n…"` 的签名头串,由 DLL 自己的释放函数(`base + 0x1d8c0`)回收。
|
|
||||||
- **不需要 init**;DLL 版本(本机样本)= `metasecbaseml 4.6.1.0`。
|
|
||||||
- 调用是**串行**的(内部有全局/TLS 状态),服务端用互斥锁包住,实测 20 并发全部成功。
|
|
||||||
|
|
||||||
## 客户端升级了怎么办(DLL 换了,偏移会变)
|
|
||||||
|
|
||||||
`--selftest` 会**大声失败**,不会静默出错。定位新偏移:
|
|
||||||
|
|
||||||
1. 从新版客户端里取 `metasecml.dll`(解包方式见 MusicServer 的 `sdk/qs/mssdk.ts` 注释)。
|
|
||||||
2. 拿真实请求自检,确认签名是否被接受(`node client.mjs --send`)。
|
|
||||||
3. 找新偏移:
|
|
||||||
- 先在 `KNOWN_VERSIONS` 里加版本,用旧偏移试;不行就:
|
|
||||||
- 用 MusicServer 仓库里的脚本脱壳 + 定位:`tmp/metasec-rebuild.py`(内存脱壳 + 重建 PE)、
|
|
||||||
`tmp/metasec-call-test.py`(直接调用试偏移)、`tmp/bdms-abi.py`(读 `bdms.node` 运行时 IAT 槽,
|
|
||||||
那里躺着真实地址)。找到后用 `--sign-rva 0xXXXXX` 验证,再写进 `KNOWN_VERSIONS`。
|
|
||||||
|
|
||||||
## 已知限制 / 未验证项
|
|
||||||
|
|
||||||
- **Wine 下未实测**(本机没有 Wine):DLL 依赖的都是 Wine 常规实现的 Win32 API(kernel32/advapi32/ole32/
|
|
||||||
winhttp/ws2_32/imm32/…),但 metasec 有设备指纹采集,行为可能和原生不同。部署前请先在目标机跑
|
|
||||||
`wine mssdk-sign.exe --selftest` 和 `node client.mjs --send`。若加载失败,先看 `WINEDEBUG=+loaddll`
|
|
||||||
的缺失依赖,必要时 `winetricks corefonts vcrun2019`、`winecfg` 里把 Windows 版本设为 Win10。
|
|
||||||
- `metasecml.dll` 是**官方专有二进制**(签名主体抖音视界),不入库、不再分发;本目录只放源码与构建脚本,
|
|
||||||
DLL 自己从官方客户端取,放 exe 同目录即可。
|
|
||||||
- 签名服务本身不联网、不存 cookie;但仍建议只监听 `127.0.0.1` 并设置 `--token`,不要暴露公网。
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# metasec 签名组件逆向记录
|
|
||||||
|
|
||||||
> 面向维护者:**汽水客户端升级后签名失效时,先看这里。**
|
|
||||||
> 相关代码:`mssdk-sign.cpp`(签名服务);构建与接口用法见同目录 `README.md`。
|
|
||||||
> 记录时间:2026-10-02,样本:`soda-mssdk/{metasecml.dll, bdms.node}`。
|
|
||||||
|
|
||||||
## 1. 速览
|
|
||||||
|
|
||||||
| 项 | 结论 |
|
|
||||||
|---|---|
|
|
||||||
| 组件身份 | 字节 metasec 官方组件,内部名 `metasecbaseml` **4.6.1.0**(2024-09-04) |
|
|
||||||
| 数字签名 | Authenticode 主体 **Douyin Vision Co., Ltd.**(DigiCert G4 Code Signing) |
|
|
||||||
| 保护 | 自解密壳 + 运行期解密字符串表;`LoadLibrary` 后内存里即为明文镜像 |
|
|
||||||
| 真实签名入口 | `metasecml.dll + 0x52af0`(**本版本**),`char* sign(const char* url, const char* headers)` |
|
|
||||||
| 需要 init 吗 | **不需要**,冷进程直接调即可出有效签名 |
|
|
||||||
| 有效性 | 已端到端验证:签名被 `api.qishui.com` 接受;不签名 / 设备不匹配 → `HTTP 200 + 0 字节` |
|
|
||||||
| 产出能力 | `X-Helios` + `X-Medusa`(部分场景附 `X-Neptune`);**不含** Argus/Ladon/Khronos/Gorgon/a_bogus |
|
|
||||||
|
|
||||||
## 2. 两个文件的关系
|
|
||||||
|
|
||||||
- `metasecml.dll`(8.8MB):算法本体。导出表只有 3 个符号,且**都是幌子**:
|
|
||||||
- `o0o0`(0x191c0):线程局部状态初始化(读 TEB→TLS 槽,条件成立时 memset 一个 0x148 字节结构)。
|
|
||||||
- `MSBridgeML` / `MSBridgeOV`(同为 0x1d010):同一个桩 —— 调一次内部函数后**无条件返回 true**,签名结果不经它返回。
|
|
||||||
- `bdms.node`(164KB):官方客户端的 N-API 胶水。对 JS 暴露 `init` / `generateHttpSignatureHeaders` / `report`;
|
|
||||||
通过**延迟导入** `metasecml.dll` 的 `o0o0`、`MSBridgeML` 来用 DLL。
|
|
||||||
|
|
||||||
## 3. 真实调用 ABI(核心)
|
|
||||||
|
|
||||||
```c
|
|
||||||
// headers: 官方展平形式 —— 每行 "名字\r\n值",行间也用 "\r\n" 连接
|
|
||||||
char* sign(const char* url, const char* headers_lines);
|
|
||||||
```
|
|
||||||
|
|
||||||
- 地址:`image base + 0x52af0`(该处是 `jmp` 跳板,实体在 0x52b00)。
|
|
||||||
- 返回:`"名字\r\n值\r\n…"` 的签名头串,堆分配;释放函数 = `image base + 0x1d8c0`。
|
|
||||||
- 线程:内部有全局/TLS 状态,**串行调用**(服务端已加互斥锁)。
|
|
||||||
- addon 是怎么拿到这个地址的:运行时 DLL 改写了它的 IAT 槽(`bdms.node` 的 `.data` 偏移 `0x25e38`),
|
|
||||||
槽里躺着的就是 `base+0x52af0` —— 这解释了「导出表说是 0x1d010,实际却在 0x52af0」的矛盾。
|
|
||||||
- 壳的形态:磁盘上只有 PE 头 + 单个 8.8MB 加密节 `.xs2`(熵≈8.0),`.text/.rdata/.data/.xs0` 均无 raw 数据;
|
|
||||||
加载时解密进内存 → **内存 dump 即得完整镜像**(重定位已修好)。
|
|
||||||
|
|
||||||
## 4. 行为规则(实测)
|
|
||||||
|
|
||||||
| 场景 | 服务端反应 |
|
|
||||||
|---|---|
|
|
||||||
| 不带签名 | `HTTP 200 + 0 字节` |
|
|
||||||
| 带正确签名 | `HTTP 200` + 正常 JSON(曲名/时长/档位齐全) |
|
|
||||||
| 签名后改 URL 里的 `device_id` | `HTTP 200 + 0 字节` → **签名与 URL 设备参数绑死** |
|
|
||||||
| headers 少一个(尤其 `cookie`、`x-ss-stub`) | `HTTP 200 + 0 字节` → 签名覆盖**全部**请求头 |
|
|
||||||
|
|
||||||
- **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也必须随请求发出。
|
|
||||||
- **场景决定头集合**:传完整真实头 → `X-Helios` + `X-Medusa`(有时附 `X-Neptune`);
|
|
||||||
只传一个 `user-agent` 这类极简头 → **只给 `X-Neptune`**(网页兜底场景)。
|
|
||||||
所以调用方必须把「真实要发出的那一份头」整套传进来。
|
|
||||||
- 输出含时间戳/nonce,**每次不同**属正常。
|
|
||||||
- 签名有效 ≠ 能拿整曲:匿名仍只有 30 秒试听(实测 236.6s 曲目 `stream_duration=30`),
|
|
||||||
**瓶颈是登录 sessionid,不是签名**。
|
|
||||||
|
|
||||||
## 5. 内部结构(RTTI 符号泄漏)
|
|
||||||
|
|
||||||
| 类 / 符号 | 作用 |
|
|
||||||
|---|---|
|
|
||||||
| `IMSHttpReqSignModule` / `MSHttpReqSignModule` | HTTP 请求签名(本服务的调用目标) |
|
|
||||||
| `IMSWebsocketReqSignModule` | WebSocket 请求签名 |
|
|
||||||
| `MSBlackBox` / `IMSBlackBoxItem` | 黑盒数据采集 |
|
|
||||||
| `IMSSecDeviceIDModule` | 设备指纹(device id) |
|
|
||||||
| `IMSRiskInspectModule` / `IMSRiskInspector` | 风控检测 |
|
|
||||||
| `IMSReportModule` | 数据上报 |
|
|
||||||
| `MSPBSInt32/64`、`MSPBString(List)`(`__ms__`) | protobuf 序列化 |
|
|
||||||
| `MSNetInterceptor::FuncInterceptor` + `Cronet_UrlRequest_Create`、`Cronet_Engine_SetMD5Header` | 注入 Chromium Cronet 网络栈(`x-ss-stub` 的 MD5 由它算) |
|
|
||||||
| `MSConfig` / `MSJSON` / `MSHttpClient` / `MSTaskManager` | 基础设施;日志用 spdlog,源路径 `utils/matrix/os/windows/ms_log.cc` |
|
|
||||||
|
|
||||||
## 6. 保护强度评估
|
|
||||||
|
|
||||||
- 壳是「自解密 + 字符串按需解密」,**没有代码虚拟化**;脱壳后是正常的 MSVC x64 代码,可读可跟。
|
|
||||||
- 敏感字符串(如 `X-Helios`)静态搜不到,运行期才解密;镜像里没找到明文标准 AES S-box
|
|
||||||
(只有 CRC32 表、Base64 表、TEA/Blowfish 类常量)。
|
|
||||||
- 结论:**算法级复刻不划算**——黑盒调用的集成成本≈0,而且能随官方升级同步。
|
|
||||||
|
|
||||||
## 7. 客户端升级后怎么重新定位偏移
|
|
||||||
|
|
||||||
`--selftest` 会**大声失败**,不会静默签出无效头。定位步骤:
|
|
||||||
|
|
||||||
1. 从新版客户端取 `metasecml.dll`(解包方式见 `sdk/qs/mssdk.ts` 头部注释)。
|
|
||||||
2. 跑 `mssdk-sign.exe --selftest --dll <新dll>`:会打印它读到的 DLL 版本;失败即偏移变了。
|
|
||||||
3. 找新偏移(仓库 `tmp/` 下现成脚本):
|
|
||||||
- `tmp/metasec-rebuild.py` —— 内存脱壳 + 重建可分析 PE;
|
|
||||||
- `tmp/bdms-abi.py` —— 读 `bdms.node` 运行时 IAT 槽,直接拿到真实签名函数地址(**最省事**);
|
|
||||||
- `tmp/metasec-call-test.py` —— 用候选偏移直接调用,看返回是否形如 `X-…\r\n…`。
|
|
||||||
4. 用 `--sign-rva 0xXXXXX` 验证,通过后写进 `mssdk-sign.cpp` 的 `KNOWN_VERSIONS`。
|
|
||||||
5. 复验:`node client.mjs --send` 应得到 `HTTP 200` + 非空 body。
|
|
||||||
|
|
||||||
## 8. 边界
|
|
||||||
|
|
||||||
- `metasecml.dll` / `bdms.node` 是**官方专有二进制**,不入库、不随代码分发;各自从自己的官方客户端取,放 exe 同目录。
|
|
||||||
- 用途限于个人自用/互操作性研究;不要对外提供签名服务,也不要用于批量刷接口。
|
|
||||||
- 签名服务本身不联网、不落 cookie;部署仍建议只监听 `127.0.0.1` 并设置 `--token`。
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Linux 上交叉编译成 Windows exe(给 Wine 跑)。
|
|
||||||
# 依赖: apt install mingw-w64 (Debian/Ubuntu)
|
|
||||||
set -euo pipefail
|
|
||||||
CXX=${CXX:-x86_64-w64-mingw32-g++}
|
|
||||||
command -v "$CXX" >/dev/null || { echo "[x] 找不到 $CXX,请先装 mingw-w64"; exit 1; }
|
|
||||||
|
|
||||||
"$CXX" -std=c++17 -O2 -Wno-unknown-pragmas \
|
|
||||||
-static -static-libgcc -static-libstdc++ \
|
|
||||||
-o mssdk-sign.exe mssdk-sign.cpp \
|
|
||||||
-pthread -lws2_32 -lversion
|
|
||||||
|
|
||||||
echo "[ok] 生成 mssdk-sign.exe(静态链接,不依赖 libstdc++/winpthread DLL)"
|
|
||||||
echo " 自检: wine ./mssdk-sign.exe --selftest --dll ./metasecml.dll"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@echo off
|
|
||||||
rem MSVC native build (Windows). Source has UTF-8 Chinese comments -> /utf-8 is required.
|
|
||||||
setlocal
|
|
||||||
set VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat
|
|
||||||
if not exist "%VCVARS%" (
|
|
||||||
echo [x] vcvars64.bat not found, edit VCVARS in this script
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
call "%VCVARS%" >nul
|
|
||||||
cl /nologo /O2 /EHsc /std:c++17 /utf-8 /W3 mssdk-sign.cpp /link /out:mssdk-sign.exe ws2_32.lib version.lib
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo [x] build failed
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo [ok] built mssdk-sign.exe
|
|
||||||
echo selftest: mssdk-sign.exe --selftest --dll .\metasecml.dll
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// mssdk-sign 的 Node 客户端示例(免依赖,Node 18+)
|
|
||||||
//
|
|
||||||
// 用法:
|
|
||||||
// node client.mjs # 自检:打 /healthz + 签一次 track_v2(不发真实请求)
|
|
||||||
// node client.mjs --send # 顺带把签名后的请求发给 api.qishui.com 验证
|
|
||||||
//
|
|
||||||
// 集成到 MusicServer 时,把 signQishuiRequest() 换成这个 HTTP 调用即可
|
|
||||||
// (签名输入必须与真实发出的头完全一致,含 x-ss-stub)。
|
|
||||||
|
|
||||||
const BASE = process.env.MSSDK_SIGN_URL ?? 'http://127.0.0.1:16889'
|
|
||||||
const TOKEN = process.env.MSSDK_SIGN_TOKEN ?? ''
|
|
||||||
|
|
||||||
const headers0 = (extra = {}) => (TOKEN ? { authorization: `Bearer ${TOKEN}`, ...extra } : extra)
|
|
||||||
|
|
||||||
/** 调本地签名服务;返回需要追加到请求上的头 */
|
|
||||||
export async function signRequest(url, headers, body) {
|
|
||||||
const res = await fetch(`${BASE}/sign`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: headers0({ 'content-type': 'application/json' }),
|
|
||||||
body: JSON.stringify({ url, headers, body }),
|
|
||||||
})
|
|
||||||
const json = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }))
|
|
||||||
if (!json.ok) throw new Error(json.error ?? `签名失败 HTTP ${res.status}`)
|
|
||||||
return { headers: json.headers, added: json.added ?? {}, elapsed_ms: json.elapsed_ms }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function healthz() {
|
|
||||||
const res = await fetch(`${BASE}/healthz`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
import { pathToFileURL } from 'node:url'
|
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
||||||
const health = await healthz()
|
|
||||||
console.log('healthz:', health)
|
|
||||||
|
|
||||||
// 与 sdk/qs/mssdk.ts 的 PC 通道同构:URL 带 device_id,headers 是真实要发出的那份
|
|
||||||
const deviceId = process.env.QS_DEVICE_ID ?? '1219822735468811019'
|
|
||||||
const iid = process.env.QS_IID ?? '1219822735468829019'
|
|
||||||
const body = JSON.stringify({ track_id: '7501674235158431760', media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
|
||||||
const q = new URLSearchParams({
|
|
||||||
aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '',
|
|
||||||
device_id: deviceId, cdid: '', 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: deviceId,
|
|
||||||
})
|
|
||||||
const url = `https://api.qishui.com/luna/pc/track_v2?${q}`
|
|
||||||
const headers = {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'user-agent': 'LunaPC/3.7.0(452316191)',
|
|
||||||
'x-luna-background-type': 'foreground',
|
|
||||||
'x-luna-is-background-req': '0',
|
|
||||||
'x-luna-is-local-user': '0',
|
|
||||||
'x-tt-trace-id': `00-${'0'.repeat(16)}-${'0'.repeat(16)}-01`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const signed = await signRequest(url, headers, body)
|
|
||||||
console.log('签名头:', Object.keys(signed.headers), `(${signed.elapsed_ms}ms)`, signed.added)
|
|
||||||
|
|
||||||
if (process.argv.includes('--send')) {
|
|
||||||
const r = await fetch(url, { method: 'POST', headers: { ...headers, ...signed.headers, ...signed.added }, body })
|
|
||||||
const t = await r.text()
|
|
||||||
console.log(`真实请求: HTTP ${r.status} ${t.length} 字节`)
|
|
||||||
if (t) {
|
|
||||||
const j = JSON.parse(t)
|
|
||||||
const vm = typeof j?.track_player?.video_model === 'string' ? JSON.parse(j.track_player.video_model) : j?.track_player?.video_model
|
|
||||||
console.log(` 曲目: ${j?.track?.name} 曲长 ${j?.track?.duration}ms 试听 ${vm?.video_duration}s`)
|
|
||||||
} else {
|
|
||||||
console.log(' 空 body = 签名没被接受')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
// mssdk-sign —— 汽水音乐(metasec)签名 HTTP 服务
|
|
||||||
//
|
|
||||||
// 为什么是它:汽水 PC 客户端自带的 metasecml.dll 提供应用级签名(X-Helios/X-Medusa,
|
|
||||||
// 部分场景附 X-Neptune)。本程序把该 DLL 关在一个进程里,对外只暴露一个 HTTP /sign,
|
|
||||||
// 于是:Windows 上直接跑;Linux 上丢进 Wine 跑,宿主机的服务(如 MusicServer)用 HTTP 调。
|
|
||||||
//
|
|
||||||
// 关于 DLL 的调用方式(2026-10-02 逆向确认,版本 metasecbaseml 4.6.1.0):
|
|
||||||
// * 导出表里的 MSBridgeML / MSBridgeOV / o0o0 都是幌子(桩/初始化),
|
|
||||||
// 真正的签名函数在 image base + 0x52af0,原型 char* f(const char* url, const char* headers)。
|
|
||||||
// * headers 是官方展平形式:每个头 "名字\r\n值",头与头之间也用 "\r\n" 连接。
|
|
||||||
// * 返回 "名字\r\n值\r\n..." 的签名头串,由 DLL 自己的释放函数(base + 0x1d8c0)回收。
|
|
||||||
// * 不需要 init;签名覆盖 URL + 全部请求头,且与 URL 里的 device_id 绑死
|
|
||||||
// (改 URL 设备号 → 服务端 200 + 空 body)。
|
|
||||||
// * 偏移随客户端版本变化:内置版本表 + 启动自检,不匹配会明确报错(见 README「升级怎么办」)。
|
|
||||||
//
|
|
||||||
// 构建(任选其一):
|
|
||||||
// Windows/MSVC : build.bat
|
|
||||||
// Linux/MinGW : x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
|
|
||||||
// -o mssdk-sign.exe mssdk-sign.cpp -lws2_32 -lversion -lwinpthread
|
|
||||||
// 运行:
|
|
||||||
// mssdk-sign.exe --port 16889 --dll .\metasecml.dll
|
|
||||||
// wine mssdk-sign.exe --port 16889 --dll ./metasecml.dll
|
|
||||||
//
|
|
||||||
// 依赖:仅 Win32 API + Winsock(无第三方库,无 C++ 异常跨模块问题)。
|
|
||||||
|
|
||||||
#include <winsock2.h>
|
|
||||||
#include <ws2tcpip.h>
|
|
||||||
#include <windows.h>
|
|
||||||
#include <winver.h>
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cctype>
|
|
||||||
#include <chrono>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
#include <functional>
|
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
|
||||||
#include <thread>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#ifdef _MSC_VER
|
|
||||||
#pragma comment(lib, "ws2_32.lib")
|
|
||||||
#pragma comment(lib, "version.lib")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 工具
|
|
||||||
|
|
||||||
static std::string toLower(std::string s) {
|
|
||||||
for (auto &c : s) c = (char)tolower((unsigned char)c);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string trim(const std::string &s) {
|
|
||||||
size_t a = s.find_first_not_of(" \t\r\n");
|
|
||||||
if (a == std::string::npos) return "";
|
|
||||||
size_t b = s.find_last_not_of(" \t\r\n");
|
|
||||||
return s.substr(a, b - a + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string jsonEscape(const std::string &s) {
|
|
||||||
std::string o;
|
|
||||||
o.reserve(s.size() + 8);
|
|
||||||
for (unsigned char c : s) {
|
|
||||||
switch (c) {
|
|
||||||
case '"': o += "\\\""; break;
|
|
||||||
case '\\': o += "\\\\"; break;
|
|
||||||
case '\n': o += "\\n"; break;
|
|
||||||
case '\r': o += "\\r"; break;
|
|
||||||
case '\t': o += "\\t"; break;
|
|
||||||
default:
|
|
||||||
if (c < 0x20) {
|
|
||||||
char b[8];
|
|
||||||
snprintf(b, sizeof b, "\\u%04x", c);
|
|
||||||
o += b;
|
|
||||||
} else {
|
|
||||||
o += (char)c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- MD5(用于 x-ss-stub = body 的 MD5 大写)
|
|
||||||
|
|
||||||
namespace md5impl {
|
|
||||||
struct Ctx {
|
|
||||||
uint32_t a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
|
|
||||||
uint64_t len = 0;
|
|
||||||
uint8_t buf[64];
|
|
||||||
size_t n = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
static const uint32_t K[64] = {
|
|
||||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
|
||||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
|
||||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
|
||||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
|
||||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
|
||||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
|
||||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
|
||||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391};
|
|
||||||
static const uint8_t S[64] = {7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
||||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
||||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
||||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21};
|
|
||||||
|
|
||||||
static inline uint32_t rotl(uint32_t x, int c) { return (x << c) | (x >> (32 - c)); }
|
|
||||||
|
|
||||||
static void block(Ctx &ctx, const uint8_t *p) {
|
|
||||||
uint32_t m[16];
|
|
||||||
for (int i = 0; i < 16; i++)
|
|
||||||
m[i] = (uint32_t)p[i * 4] | ((uint32_t)p[i * 4 + 1] << 8) | ((uint32_t)p[i * 4 + 2] << 16) |
|
|
||||||
((uint32_t)p[i * 4 + 3] << 24);
|
|
||||||
uint32_t a = ctx.a, b = ctx.b, c = ctx.c, d = ctx.d;
|
|
||||||
for (int i = 0; i < 64; i++) {
|
|
||||||
uint32_t f, g;
|
|
||||||
if (i < 16) { f = (b & c) | (~b & d); g = (uint32_t)i; }
|
|
||||||
else if (i < 32) { f = (d & b) | (~d & c); g = (uint32_t)(5 * i + 1) & 15; }
|
|
||||||
else if (i < 48) { f = b ^ c ^ d; g = (uint32_t)(3 * i + 5) & 15; }
|
|
||||||
else { f = c ^ (b | ~d); g = (uint32_t)(7 * i) & 15; }
|
|
||||||
uint32_t t = d;
|
|
||||||
d = c;
|
|
||||||
c = b;
|
|
||||||
b = b + rotl(a + f + K[i] + m[g], S[i]);
|
|
||||||
a = t;
|
|
||||||
}
|
|
||||||
ctx.a += a; ctx.b += b; ctx.c += c; ctx.d += d;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void update(Ctx &ctx, const void *data, size_t len) {
|
|
||||||
const uint8_t *p = (const uint8_t *)data;
|
|
||||||
ctx.len += len;
|
|
||||||
while (len) {
|
|
||||||
size_t take = 64 - ctx.n;
|
|
||||||
if (take > len) take = len;
|
|
||||||
memcpy(ctx.buf + ctx.n, p, take);
|
|
||||||
ctx.n += take; p += take; len -= take;
|
|
||||||
if (ctx.n == 64) { block(ctx, ctx.buf); ctx.n = 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string hex(const Ctx &in) {
|
|
||||||
Ctx ctx = in;
|
|
||||||
uint64_t bits = ctx.len * 8;
|
|
||||||
uint8_t pad = 0x80;
|
|
||||||
update(ctx, &pad, 1);
|
|
||||||
uint8_t zero = 0;
|
|
||||||
while (ctx.n != 56) update(ctx, &zero, 1);
|
|
||||||
uint8_t lenb[8];
|
|
||||||
for (int i = 0; i < 8; i++) lenb[i] = (uint8_t)(bits >> (8 * i));
|
|
||||||
update(ctx, lenb, 8);
|
|
||||||
uint32_t v[4] = {ctx.a, ctx.b, ctx.c, ctx.d};
|
|
||||||
static const char *H = "0123456789abcdef";
|
|
||||||
std::string out;
|
|
||||||
out.reserve(32);
|
|
||||||
for (int i = 0; i < 4; i++)
|
|
||||||
for (int j = 0; j < 4; j++) {
|
|
||||||
out += H[(v[i] >> (8 * j + 4)) & 0xf];
|
|
||||||
out += H[(v[i] >> (8 * j)) & 0xf];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
} // namespace md5impl
|
|
||||||
|
|
||||||
static std::string md5HexUpper(const std::string &data) {
|
|
||||||
md5impl::Ctx ctx;
|
|
||||||
md5impl::update(ctx, data.data(), data.size());
|
|
||||||
std::string h = md5impl::hex(ctx);
|
|
||||||
for (auto &c : h) c = (char)toupper((unsigned char)c);
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 极简 JSON(只支持本服务需要的子集)
|
|
||||||
|
|
||||||
struct JVal {
|
|
||||||
enum T { Nul, Bool, Num, Str, Arr, Obj } t = Nul;
|
|
||||||
std::string s; // Str / Num / Bool 的原始文本
|
|
||||||
std::vector<JVal> a; // Arr
|
|
||||||
std::vector<std::pair<std::string, JVal>> o; // Obj(保序)
|
|
||||||
const JVal *get(const std::string &k) const {
|
|
||||||
for (auto &kv : o)
|
|
||||||
if (kv.first == k) return &kv.second;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
namespace json {
|
|
||||||
struct P {
|
|
||||||
const std::string &s;
|
|
||||||
size_t i = 0;
|
|
||||||
explicit P(const std::string &src) : s(src) {}
|
|
||||||
void ws() { while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) i++; }
|
|
||||||
bool lit(const char *w) {
|
|
||||||
size_t n = strlen(w);
|
|
||||||
if (s.compare(i, n, w) == 0) { i += n; return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
void utf8(uint32_t cp, std::string &out) {
|
|
||||||
if (cp < 0x80) out += (char)cp;
|
|
||||||
else if (cp < 0x800) { out += (char)(0xC0 | (cp >> 6)); out += (char)(0x80 | (cp & 0x3F)); }
|
|
||||||
else if (cp < 0x10000) {
|
|
||||||
out += (char)(0xE0 | (cp >> 12));
|
|
||||||
out += (char)(0x80 | ((cp >> 6) & 0x3F));
|
|
||||||
out += (char)(0x80 | (cp & 0x3F));
|
|
||||||
} else {
|
|
||||||
out += (char)(0xF0 | (cp >> 18));
|
|
||||||
out += (char)(0x80 | ((cp >> 12) & 0x3F));
|
|
||||||
out += (char)(0x80 | ((cp >> 6) & 0x3F));
|
|
||||||
out += (char)(0x80 | (cp & 0x3F));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bool hex4(uint32_t &v) {
|
|
||||||
v = 0;
|
|
||||||
for (int k = 0; k < 4; k++) {
|
|
||||||
if (i >= s.size()) return false;
|
|
||||||
char c = s[i++];
|
|
||||||
v <<= 4;
|
|
||||||
if (c >= '0' && c <= '9') v |= (uint32_t)(c - '0');
|
|
||||||
else if (c >= 'a' && c <= 'f') v |= (uint32_t)(c - 'a' + 10);
|
|
||||||
else if (c >= 'A' && c <= 'F') v |= (uint32_t)(c - 'A' + 10);
|
|
||||||
else return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
bool str(std::string &out) {
|
|
||||||
if (i >= s.size() || s[i] != '"') return false;
|
|
||||||
i++;
|
|
||||||
out.clear();
|
|
||||||
while (i < s.size()) {
|
|
||||||
char c = s[i++];
|
|
||||||
if (c == '"') return true;
|
|
||||||
if (c != '\\') { out += c; continue; }
|
|
||||||
if (i >= s.size()) return false;
|
|
||||||
char e = s[i++];
|
|
||||||
switch (e) {
|
|
||||||
case '"': out += '"'; break;
|
|
||||||
case '\\': out += '\\'; break;
|
|
||||||
case '/': out += '/'; break;
|
|
||||||
case 'b': out += '\b'; break;
|
|
||||||
case 'f': out += '\f'; break;
|
|
||||||
case 'n': out += '\n'; break;
|
|
||||||
case 'r': out += '\r'; break;
|
|
||||||
case 't': out += '\t'; break;
|
|
||||||
case 'u': {
|
|
||||||
uint32_t cp;
|
|
||||||
if (!hex4(cp)) return false;
|
|
||||||
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') {
|
|
||||||
size_t save = i;
|
|
||||||
i += 2;
|
|
||||||
uint32_t lo;
|
|
||||||
if (hex4(lo) && lo >= 0xDC00 && lo <= 0xDFFF)
|
|
||||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
|
|
||||||
else
|
|
||||||
i = save;
|
|
||||||
}
|
|
||||||
utf8(cp, out);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
bool val(JVal &v) {
|
|
||||||
ws();
|
|
||||||
if (i >= s.size()) return false;
|
|
||||||
char c = s[i];
|
|
||||||
if (c == '{') {
|
|
||||||
v.t = JVal::Obj;
|
|
||||||
i++;
|
|
||||||
ws();
|
|
||||||
if (i < s.size() && s[i] == '}') { i++; return true; }
|
|
||||||
while (true) {
|
|
||||||
ws();
|
|
||||||
std::string k;
|
|
||||||
if (!str(k)) return false;
|
|
||||||
ws();
|
|
||||||
if (i >= s.size() || s[i] != ':') return false;
|
|
||||||
i++;
|
|
||||||
JVal cv;
|
|
||||||
if (!val(cv)) return false;
|
|
||||||
v.o.emplace_back(std::move(k), std::move(cv));
|
|
||||||
ws();
|
|
||||||
if (i < s.size() && s[i] == ',') { i++; continue; }
|
|
||||||
if (i < s.size() && s[i] == '}') { i++; return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (c == '[') {
|
|
||||||
v.t = JVal::Arr;
|
|
||||||
i++;
|
|
||||||
ws();
|
|
||||||
if (i < s.size() && s[i] == ']') { i++; return true; }
|
|
||||||
while (true) {
|
|
||||||
JVal cv;
|
|
||||||
if (!val(cv)) return false;
|
|
||||||
v.a.push_back(std::move(cv));
|
|
||||||
ws();
|
|
||||||
if (i < s.size() && s[i] == ',') { i++; continue; }
|
|
||||||
if (i < s.size() && s[i] == ']') { i++; return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (c == '"') { v.t = JVal::Str; return str(v.s); }
|
|
||||||
if (lit("true")) { v.t = JVal::Bool; v.s = "true"; return true; }
|
|
||||||
if (lit("false")) { v.t = JVal::Bool; v.s = "false"; return true; }
|
|
||||||
if (lit("null")) { v.t = JVal::Nul; return true; }
|
|
||||||
size_t b = i;
|
|
||||||
while (i < s.size() && (isdigit((unsigned char)s[i]) || s[i] == '-' || s[i] == '+' || s[i] == '.' ||
|
|
||||||
s[i] == 'e' || s[i] == 'E'))
|
|
||||||
i++;
|
|
||||||
if (i == b) return false;
|
|
||||||
v.t = JVal::Num;
|
|
||||||
v.s = s.substr(b, i - b);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} // namespace json
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 签名器
|
|
||||||
|
|
||||||
typedef char *(*SignFn)(const char *, const char *);
|
|
||||||
typedef void (*FreeFn)(void *);
|
|
||||||
|
|
||||||
// SEH 必须放在不含 C++ 析构对象的函数里(否则 MSVC 报 C2712)
|
|
||||||
#ifdef _MSC_VER
|
|
||||||
static char *callSignRaw(SignFn fn, const char *url, const char *headers) {
|
|
||||||
__try {
|
|
||||||
return fn(url, headers);
|
|
||||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct RvaPair {
|
|
||||||
uint64_t sign;
|
|
||||||
uint64_t freefn;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 已知版本 → 偏移(客户端升级后新增一行;见 README)
|
|
||||||
static const struct {
|
|
||||||
const char *version;
|
|
||||||
RvaPair rva;
|
|
||||||
} KNOWN_VERSIONS[] = {
|
|
||||||
{"4.6.1.0", {0x52af0, 0x1d8c0}},
|
|
||||||
};
|
|
||||||
static const RvaPair DEFAULT_RVA = {0x52af0, 0x1d8c0};
|
|
||||||
|
|
||||||
class Signer {
|
|
||||||
public:
|
|
||||||
bool load(const std::string &dllPath, uint64_t rvaOverride, std::string &err) {
|
|
||||||
dllPath_ = dllPath;
|
|
||||||
HMODULE m = LoadLibraryExW(toWide(dllPath).c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
|
|
||||||
if (!m) {
|
|
||||||
char b[256];
|
|
||||||
snprintf(b, sizeof b, "LoadLibraryExW 失败(错误码 %lu): %s", GetLastError(), dllPath.c_str());
|
|
||||||
err = b;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
mod_ = m;
|
|
||||||
version_ = readVersion(dllPath);
|
|
||||||
|
|
||||||
RvaPair use = DEFAULT_RVA;
|
|
||||||
if (rvaOverride) {
|
|
||||||
use = {rvaOverride, rvaOverride + (DEFAULT_RVA.freefn - DEFAULT_RVA.sign)};
|
|
||||||
err.clear();
|
|
||||||
} else {
|
|
||||||
bool found = false;
|
|
||||||
for (auto &kv : KNOWN_VERSIONS) {
|
|
||||||
if (version_ == kv.version) { use = kv.rva; found = true; break; }
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
char b[256];
|
|
||||||
snprintf(b, sizeof b,
|
|
||||||
"DLL 版本 %s 不在已知表内(已知: 4.6.1.0)。将按默认偏移 %#llx 试跑,"
|
|
||||||
"失败请用 --sign-rva 指定(见 README)",
|
|
||||||
version_.empty() ? "(读不到)" : version_.c_str(), (unsigned long long)DEFAULT_RVA.sign);
|
|
||||||
warn_ = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sign_ = (SignFn)((uint8_t *)mod_ + use.sign);
|
|
||||||
free_ = (FreeFn)((uint8_t *)mod_ + use.freefn);
|
|
||||||
rva_ = use;
|
|
||||||
|
|
||||||
// 启动自检:签一个探针,确认偏移正确(不发网络请求)
|
|
||||||
if (!selfTest()) {
|
|
||||||
err = "签名自检失败:偏移 " + hexStr(use.sign) + " 处不是有效的签名函数。"
|
|
||||||
"客户端升级后请重新定位(README「升级怎么办」)";
|
|
||||||
sign_ = nullptr;
|
|
||||||
free_ = nullptr;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ready() const { return sign_ != nullptr; }
|
|
||||||
std::string version() const { return version_; }
|
|
||||||
std::string warn() const { return warn_; }
|
|
||||||
std::string dllPath() const { return dllPath_; }
|
|
||||||
uint64_t signRva() const { return rva_.sign; }
|
|
||||||
|
|
||||||
// 返回 "名字\r\n值\r\n..." 的签名头串;失败返回空
|
|
||||||
std::string sign(const std::string &url, const std::string &headersFlat) {
|
|
||||||
if (!sign_) return "";
|
|
||||||
std::lock_guard<std::mutex> lk(mu_); // DLL 内部有全局/TLS 状态,串行调用最稳
|
|
||||||
#ifdef _MSC_VER
|
|
||||||
char *r = callSignRaw(sign_, url.c_str(), headersFlat.c_str());
|
|
||||||
#else
|
|
||||||
// MinGW 无 MSVC 式 __try/__except;调用异常会直接终止进程(签名偏移错时会立刻暴露)
|
|
||||||
char *r = sign_(url.c_str(), headersFlat.c_str());
|
|
||||||
#endif
|
|
||||||
if (!r) return "";
|
|
||||||
std::string out = r;
|
|
||||||
if (free_) free_(r);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
static std::wstring toWide(const std::string &s) {
|
|
||||||
int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0);
|
|
||||||
std::wstring w(n > 0 ? n - 1 : 0, L'\0');
|
|
||||||
if (n > 0) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &w[0], n);
|
|
||||||
return w;
|
|
||||||
}
|
|
||||||
static std::string hexStr(uint64_t v) {
|
|
||||||
char b[32];
|
|
||||||
snprintf(b, sizeof b, "0x%llx", (unsigned long long)v);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
static std::string readVersion(const std::string &path) {
|
|
||||||
DWORD dummy = 0;
|
|
||||||
DWORD sz = GetFileVersionInfoSizeW(toWide(path).c_str(), &dummy);
|
|
||||||
if (!sz) return "";
|
|
||||||
std::vector<char> buf(sz);
|
|
||||||
if (!GetFileVersionInfoW(toWide(path).c_str(), 0, sz, buf.data())) return "";
|
|
||||||
VS_FIXEDFILEINFO *fi = nullptr;
|
|
||||||
UINT len = 0;
|
|
||||||
if (!VerQueryValueW(buf.data(), L"\\", (void **)&fi, &len) || !fi) return "";
|
|
||||||
char b[64];
|
|
||||||
snprintf(b, sizeof b, "%u.%u.%u.%u", HIWORD(fi->dwFileVersionMS), LOWORD(fi->dwFileVersionMS),
|
|
||||||
HIWORD(fi->dwFileVersionLS), LOWORD(fi->dwFileVersionLS));
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
bool selfTest() {
|
|
||||||
const char *probeUrl =
|
|
||||||
"https://api.qishui.com/luna/pc/track_v2?aid=386088&app_name=luna_pc&device_id=1";
|
|
||||||
const char *probeHdr = "user-agent\r\nLunaPC/3.7.0(452316191)";
|
|
||||||
std::string r = sign(probeUrl, probeHdr);
|
|
||||||
return r.find("X-") != std::string::npos && r.find("\r\n") != std::string::npos;
|
|
||||||
}
|
|
||||||
|
|
||||||
HMODULE mod_ = nullptr;
|
|
||||||
SignFn sign_ = nullptr;
|
|
||||||
FreeFn free_ = nullptr;
|
|
||||||
RvaPair rva_{};
|
|
||||||
std::string dllPath_, version_, warn_;
|
|
||||||
std::mutex mu_;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- HTTP 服务
|
|
||||||
|
|
||||||
struct Config {
|
|
||||||
std::string bind = "127.0.0.1";
|
|
||||||
int port = 16889;
|
|
||||||
std::string dll;
|
|
||||||
std::string token; // 非空则要求 Bearer / X-Auth-Token
|
|
||||||
uint64_t signRva = 0; // 0 = 用版本表
|
|
||||||
bool selftestOnly = false;
|
|
||||||
bool verbose = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
static std::string httpDate() {
|
|
||||||
SYSTEMTIME st;
|
|
||||||
GetSystemTime(&st);
|
|
||||||
char b[64];
|
|
||||||
snprintf(b, sizeof b, "%04d-%02d-%02dT%02d:%02d:%02dZ", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute,
|
|
||||||
st.wSecond);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string respJson(int code, const std::string &body) {
|
|
||||||
const char *reason = code == 200 ? "OK" : (code == 400 ? "Bad Request" : (code == 401 ? "Unauthorized" : (code == 404 ? "Not Found" : "Internal Server Error")));
|
|
||||||
char head[512];
|
|
||||||
snprintf(head, sizeof head,
|
|
||||||
"HTTP/1.1 %d %s\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: %zu\r\n"
|
|
||||||
"Connection: close\r\nServer: mssdk-sign/0.1.0\r\n\r\n",
|
|
||||||
code, reason, body.size());
|
|
||||||
return std::string(head) + body;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string errBody(const std::string &msg) { return "{\"ok\":false,\"error\":\"" + jsonEscape(msg) + "\"}"; }
|
|
||||||
|
|
||||||
static bool sendAll(SOCKET s, const std::string &data) {
|
|
||||||
size_t sent = 0;
|
|
||||||
while (sent < data.size()) {
|
|
||||||
int n = send(s, data.data() + sent, (int)(data.size() - sent), 0);
|
|
||||||
if (n <= 0) return false;
|
|
||||||
sent += (size_t)n;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读一个完整请求(headers + body)
|
|
||||||
static bool readRequest(SOCKET s, std::string &method, std::string &path, std::string &headersRaw,
|
|
||||||
std::string &body) {
|
|
||||||
std::string buf;
|
|
||||||
char tmp[4096];
|
|
||||||
size_t headEnd = std::string::npos;
|
|
||||||
while ((headEnd = buf.find("\r\n\r\n")) == std::string::npos) {
|
|
||||||
int n = recv(s, tmp, sizeof tmp, 0);
|
|
||||||
if (n <= 0) return false;
|
|
||||||
buf.append(tmp, n);
|
|
||||||
if (buf.size() > 256 * 1024) return false;
|
|
||||||
}
|
|
||||||
headersRaw = buf.substr(0, headEnd);
|
|
||||||
size_t lineEnd = headersRaw.find("\r\n");
|
|
||||||
std::string reqLine = headersRaw.substr(0, lineEnd == std::string::npos ? headersRaw.size() : lineEnd);
|
|
||||||
size_t sp1 = reqLine.find(' '), sp2 = reqLine.rfind(' ');
|
|
||||||
if (sp1 == std::string::npos || sp2 == std::string::npos || sp2 <= sp1) return false;
|
|
||||||
method = reqLine.substr(0, sp1);
|
|
||||||
path = reqLine.substr(sp1 + 1, sp2 - sp1 - 1);
|
|
||||||
|
|
||||||
size_t contentLength = 0;
|
|
||||||
{
|
|
||||||
std::string h = toLower(headersRaw);
|
|
||||||
size_t p = h.find("content-length:");
|
|
||||||
if (p != std::string::npos) contentLength = (size_t)strtoul(headersRaw.c_str() + p + 15, nullptr, 10);
|
|
||||||
}
|
|
||||||
if (contentLength > 4 * 1024 * 1024) return false;
|
|
||||||
body = buf.substr(headEnd + 4);
|
|
||||||
while (body.size() < contentLength) {
|
|
||||||
int n = recv(s, tmp, sizeof tmp, 0);
|
|
||||||
if (n <= 0) break;
|
|
||||||
body.append(tmp, n);
|
|
||||||
}
|
|
||||||
if (body.size() > contentLength) body.resize(contentLength);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool authorized(const Config &cfg, const std::string &headersRaw) {
|
|
||||||
if (cfg.token.empty()) return true;
|
|
||||||
std::string h = toLower(headersRaw);
|
|
||||||
std::string want = toLower(cfg.token);
|
|
||||||
if (h.find("authorization: bearer " + want) != std::string::npos) return true;
|
|
||||||
if (h.find("x-auth-token: " + want) != std::string::npos) return true;
|
|
||||||
if (h.find("?token=" + want) != std::string::npos) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string handleSign(Signer &signer, const std::string &body, bool verbose) {
|
|
||||||
JVal root;
|
|
||||||
json::P p(body);
|
|
||||||
if (!p.val(root) || root.t != JVal::Obj) return respJson(400, errBody("请求体不是 JSON 对象"));
|
|
||||||
|
|
||||||
const JVal *jurl = root.get("url");
|
|
||||||
if (!jurl || jurl->t != JVal::Str || jurl->s.empty()) return respJson(400, errBody("缺少 url 字段"));
|
|
||||||
|
|
||||||
// 保序收集 headers
|
|
||||||
std::vector<std::pair<std::string, std::string>> headers;
|
|
||||||
bool hasStub = false;
|
|
||||||
if (const JVal *jh = root.get("headers")) {
|
|
||||||
if (jh->t != JVal::Obj) return respJson(400, errBody("headers 必须是对象"));
|
|
||||||
for (auto &kv : jh->o) {
|
|
||||||
if (kv.second.t != JVal::Str) continue; // 只签名字符串头
|
|
||||||
headers.emplace_back(kv.first, kv.second.s);
|
|
||||||
if (toLower(kv.first) == "x-ss-stub") hasStub = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (headers.empty()) return respJson(400, errBody("headers 不能为空(签名覆盖全部请求头,必须传真实要发出的那一份)"));
|
|
||||||
|
|
||||||
// 给了 body 且没带 x-ss-stub → 补上(body 的 MD5 大写),与官方客户端一致
|
|
||||||
std::string addedStub;
|
|
||||||
if (const JVal *jb = root.get("body")) {
|
|
||||||
if (jb->t == JVal::Str && !hasStub) {
|
|
||||||
addedStub = md5HexUpper(jb->s);
|
|
||||||
headers.emplace_back("x-ss-stub", addedStub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string flat;
|
|
||||||
for (size_t i = 0; i < headers.size(); i++) {
|
|
||||||
if (i) flat += "\r\n";
|
|
||||||
flat += headers[i].first + "\r\n" + headers[i].second;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto t0 = std::chrono::steady_clock::now();
|
|
||||||
std::string raw = signer.sign(jurl->s, flat);
|
|
||||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count();
|
|
||||||
|
|
||||||
if (raw.empty())
|
|
||||||
return respJson(500, errBody("签名失败(DLL 未返回内容;检查 headers 是否与将要发出的完全一致)"));
|
|
||||||
|
|
||||||
// 解析 "名字\r\n值\r\n..." 成对
|
|
||||||
std::vector<std::string> f;
|
|
||||||
{
|
|
||||||
size_t b = 0;
|
|
||||||
while (b <= raw.size()) {
|
|
||||||
size_t e = raw.find("\r\n", b);
|
|
||||||
if (e == std::string::npos) { f.push_back(raw.substr(b)); break; }
|
|
||||||
f.push_back(raw.substr(b, e - b));
|
|
||||||
b = e + 2;
|
|
||||||
}
|
|
||||||
while (!f.empty() && f.back().empty()) f.pop_back();
|
|
||||||
}
|
|
||||||
if (f.size() < 2 || (f.size() % 2) != 0) return respJson(500, errBody("DLL 返回格式异常"));
|
|
||||||
|
|
||||||
std::string out = "{\"ok\":true,\"headers\":{";
|
|
||||||
for (size_t i = 0; i + 1 < f.size(); i += 2) {
|
|
||||||
if (i) out += ",";
|
|
||||||
out += "\"" + jsonEscape(f[i]) + "\":\"" + jsonEscape(f[i + 1]) + "\"";
|
|
||||||
}
|
|
||||||
out += "},\"elapsed_ms\":" + std::to_string(ms);
|
|
||||||
if (!addedStub.empty()) out += ",\"added\":{\"x-ss-stub\":\"" + addedStub + "\"}";
|
|
||||||
out += "}";
|
|
||||||
if (verbose) printf("[sign] %s -> %zu 个签名头, %lldms\n", jurl->s.substr(0, 80).c_str(), f.size() / 2, (long long)ms);
|
|
||||||
return respJson(200, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void handleConn(SOCKET s, const Config &cfg, Signer &signer) {
|
|
||||||
std::string method, path, headersRaw, body;
|
|
||||||
if (!readRequest(s, method, path, headersRaw, body)) { closesocket(s); return; }
|
|
||||||
std::string route = path;
|
|
||||||
size_t q = route.find('?');
|
|
||||||
if (q != std::string::npos) route = route.substr(0, q);
|
|
||||||
|
|
||||||
std::string resp;
|
|
||||||
if (route == "/healthz") {
|
|
||||||
std::string j = "{\"ok\":true,\"service\":\"mssdk-sign\",\"version\":\"0.1.0\",\"signer\":\"" +
|
|
||||||
jsonEscape(signer.dllPath()) + "\",\"dllVersion\":\"" + jsonEscape(signer.version()) +
|
|
||||||
"\",\"signRva\":\"" + [&] {
|
|
||||||
char b[32];
|
|
||||||
snprintf(b, sizeof b, "0x%llx", (unsigned long long)signer.signRva());
|
|
||||||
return std::string(b);
|
|
||||||
}() + "\",\"auth\":" + (cfg.token.empty() ? "false" : "true") + "}";
|
|
||||||
resp = respJson(200, j);
|
|
||||||
} else if (route == "/config") {
|
|
||||||
if (!authorized(cfg, headersRaw)) {
|
|
||||||
resp = respJson(401, errBody("缺少或错误的 token"));
|
|
||||||
} else {
|
|
||||||
resp = respJson(200, "{\"ok\":true,\"signRva\":\"" + std::string("see /healthz") +
|
|
||||||
"\",\"headersOrder\":\"按请求里的顺序签名,响应里的 headers 需原样发出\"}");
|
|
||||||
}
|
|
||||||
} else if (route == "/sign") {
|
|
||||||
if (method != "POST") resp = respJson(400, errBody("请用 POST /sign"));
|
|
||||||
else if (!authorized(cfg, headersRaw)) resp = respJson(401, errBody("缺少或错误的 token"));
|
|
||||||
else resp = handleSign(signer, body, cfg.verbose);
|
|
||||||
} else if (route == "/") {
|
|
||||||
resp = respJson(200, "{\"ok\":true,\"usage\":\"POST /sign {url,headers,body?} | GET /healthz\"}");
|
|
||||||
} else {
|
|
||||||
resp = respJson(404, errBody("未知路径"));
|
|
||||||
}
|
|
||||||
sendAll(s, resp);
|
|
||||||
closesocket(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void printUsage() {
|
|
||||||
printf(
|
|
||||||
"mssdk-sign —— 汽水音乐 metasec 签名服务\n"
|
|
||||||
"用法: mssdk-sign [选项]\n"
|
|
||||||
" --port <n> 监听端口(默认 16889)\n"
|
|
||||||
" --bind <addr> 绑定地址(默认 127.0.0.1;容器内跨机调用用 0.0.0.0)\n"
|
|
||||||
" --dll <path> metasecml.dll 路径(默认 exe 同目录 metasecml.dll)\n"
|
|
||||||
" --token <t> 启用鉴权(Bearer / X-Auth-Token)\n"
|
|
||||||
" --sign-rva <hex> 手动指定签名函数偏移(默认按 DLL 版本表)\n"
|
|
||||||
" --selftest 只做加载+签名自检后退出\n"
|
|
||||||
" -v 打印每个请求的签名日志\n"
|
|
||||||
"接口:\n"
|
|
||||||
" GET /healthz\n"
|
|
||||||
" POST /sign {\"url\":\"https://...\",\"body\":\"可选\",\"headers\":{\"user-agent\":\"...\"}}\n"
|
|
||||||
" → {\"ok\":true,\"headers\":{\"X-Helios\":\"...\",\"X-Medusa\":\"...\"}}\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string exeDir() {
|
|
||||||
char b[MAX_PATH];
|
|
||||||
DWORD n = GetModuleFileNameA(nullptr, b, MAX_PATH);
|
|
||||||
std::string p(b, n);
|
|
||||||
size_t s = p.find_last_of("\\/");
|
|
||||||
return s == std::string::npos ? "." : p.substr(0, s);
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
Config cfg;
|
|
||||||
for (int i = 1; i < argc; i++) {
|
|
||||||
std::string a = argv[i];
|
|
||||||
auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : ""; };
|
|
||||||
if (a == "--port") cfg.port = atoi(next().c_str());
|
|
||||||
else if (a == "--bind") cfg.bind = next();
|
|
||||||
else if (a == "--dll") cfg.dll = next();
|
|
||||||
else if (a == "--token") cfg.token = next();
|
|
||||||
else if (a == "--sign-rva") cfg.signRva = strtoull(next().c_str(), nullptr, 16);
|
|
||||||
else if (a == "--selftest") cfg.selftestOnly = true;
|
|
||||||
else if (a == "-v") cfg.verbose = true;
|
|
||||||
else if (a == "-h" || a == "--help") { printUsage(); return 0; }
|
|
||||||
}
|
|
||||||
if (cfg.dll.empty()) cfg.dll = exeDir() + "\\metasecml.dll";
|
|
||||||
|
|
||||||
// MD5 自检(防止 x-ss-stub 算错)
|
|
||||||
if (md5HexUpper("abc") != "900150983CD24FB0D6963F7D28E17F72") {
|
|
||||||
fprintf(stderr, "MD5 自检失败,拒绝启动\n");
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
Signer signer;
|
|
||||||
std::string err;
|
|
||||||
if (!signer.load(cfg.dll, cfg.signRva, err)) {
|
|
||||||
fprintf(stderr, "签名器加载失败: %s\n", err.c_str());
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
if (!signer.warn().empty()) fprintf(stderr, "警告: %s\n", signer.warn().c_str());
|
|
||||||
printf("签名器就绪: %s (DLL 版本 %s, 签名偏移 0x%llx)\n", cfg.dll.c_str(),
|
|
||||||
signer.version().empty() ? "未知" : signer.version().c_str(), (unsigned long long)signer.signRva());
|
|
||||||
if (cfg.selftestOnly) {
|
|
||||||
printf("自检通过。\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
WSADATA wsa;
|
|
||||||
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
|
|
||||||
fprintf(stderr, "WSAStartup 失败\n");
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
SOCKET srv = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
|
||||||
if (srv == INVALID_SOCKET) {
|
|
||||||
fprintf(stderr, "socket 失败\n");
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
BOOL yes = TRUE;
|
|
||||||
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof yes);
|
|
||||||
sockaddr_in addr{};
|
|
||||||
addr.sin_family = AF_INET;
|
|
||||||
addr.sin_port = htons((u_short)cfg.port);
|
|
||||||
inet_pton(AF_INET, cfg.bind.c_str(), &addr.sin_addr);
|
|
||||||
if (bind(srv, (sockaddr *)&addr, sizeof addr) != 0 || listen(srv, 32) != 0) {
|
|
||||||
fprintf(stderr, "bind/listen %s:%d 失败(错误码 %d)\n", cfg.bind.c_str(), cfg.port, WSAGetLastError());
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
printf("监听 http://%s:%d (/healthz, POST /sign)%s\n", cfg.bind.c_str(), cfg.port,
|
|
||||||
cfg.token.empty() ? "" : " [已启用 token 鉴权]");
|
|
||||||
fflush(stdout);
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
SOCKET c = accept(srv, nullptr, nullptr);
|
|
||||||
if (c == INVALID_SOCKET) continue;
|
|
||||||
DWORD tv = 15000;
|
|
||||||
setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof tv);
|
|
||||||
setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, (const char *)&tv, sizeof tv);
|
|
||||||
std::thread(handleConn, c, std::cref(cfg), std::ref(signer)).detach();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user