mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 10:24:47 +00:00
chore: 暂存更改
This commit is contained in:
1 parent
75678ac0a4
commit
20e7a6fc79
38 files changed
+8807
-1382
No files matched your search
@@ -0,0 +1,191 @@
|
||||
// 音频 CDN 的取流通道。
|
||||
//
|
||||
// 为什么不用页面 fetch:番茄网页站的 CSP(report-only)里 connect-src 没有任何
|
||||
// *vod.com 域名,音频 CDN 一个都不在白名单上。report-only 不会真拦请求,但每次
|
||||
// 都会往 mon.zijieapi.com/monitor_browser/collect/batch/security/ 上报一条违规,
|
||||
// 等于把听书行为连着 URL 一起报给服务端。所以走 GM_xmlhttpRequest:
|
||||
// 它在页面 CSP 之外,既不触发上报,也天然不带页面 Referer 和 Cookie。
|
||||
//
|
||||
// 试过换 aid 让服务端返回白名单内的域名,行不通 —— CDN 域名跟着 aid 所属产品走:
|
||||
// 1967 番茄小说 -> *.fqnovelvod.com 3040 番茄畅听 -> *.novelfmvod.com
|
||||
// 13/35 头条 -> *.toutiaovod.com 6589 -> *.wkbrowser.com
|
||||
// 1217/1128/8478/32/596084 -> PARAM_INVALID(不支持听书接口)
|
||||
// 没有一个落在 connect-src 里,而且签名里的 aid 必须与 query 一致,
|
||||
// 否则网关直接回 200 空响应体。
|
||||
|
||||
type AnyResponse = Tampermonkey.Response<unknown> & { response?: unknown }
|
||||
|
||||
/**
|
||||
* 用 GM_xmlhttpRequest 实现的、够播放器用的 fetch 子集。
|
||||
*
|
||||
* 只支持播放器实际用到的部分:GET + Range + AbortSignal + 流式 body。
|
||||
*
|
||||
* 关于 responseType 的坑(踩过两次,记在这):
|
||||
* - `'stream'`:TM 会把 ReadableStream 放进 res.response,但**不是**在
|
||||
* readyState 2 那一刻就绪。在 readyState 2 判断 instanceof 永远失败。
|
||||
* - `'arraybuffer'`:res.response 只在 readyState 4 才有值,onprogress
|
||||
* 期间一直是 null,拿不到增量。
|
||||
* - `responseText`:绝对不能用来还原二进制。它按字符解码过,>0xFF 的码位
|
||||
* 已经丢了原始字节,喂进 MSE 会直接报 SourceBuffer 更新失败。
|
||||
* 所以这里两种都试:优先等 'stream',拿不到就退回整段 arraybuffer。
|
||||
*/
|
||||
export function gmAudioFetch(
|
||||
input: string | URL | Request,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
const headers = normalizeHeaders(init.headers)
|
||||
const signal = init.signal ?? null
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortError())
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let request: { abort: () => void } | undefined
|
||||
|
||||
const onAbort = (): void => {
|
||||
try { request?.abort() } catch { /* 尚未开始或已结束 */ }
|
||||
if (settled) return
|
||||
settled = true
|
||||
reject(abortError())
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const cleanup = (): void => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const succeed = (res: Tampermonkey.ResponseBase, body: BodyInit | null): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(makeResponse(res, body))
|
||||
}
|
||||
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
request = GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers,
|
||||
responseType: 'stream',
|
||||
// GM 请求不带页面 Referer;anonymous 同时也不带 Cookie
|
||||
anonymous: true,
|
||||
onreadystatechange(res) {
|
||||
// readyState 3/4 时 TM 才把 stream 挂上来。拿到就立刻交出去,
|
||||
// 播放器那边就能边下边解密,不必等整段下完
|
||||
if (settled || res.readyState < 3) return
|
||||
const stream = (res as AnyResponse).response
|
||||
if (stream instanceof ReadableStream) succeed(res, stream)
|
||||
},
|
||||
onload(res) {
|
||||
if (settled) return
|
||||
// 没走成 stream:这时 readyState 已是 4,response 里是完整数据
|
||||
const stream = (res as AnyResponse).response
|
||||
if (stream instanceof ReadableStream) {
|
||||
succeed(res, stream)
|
||||
return
|
||||
}
|
||||
const bytes = toBytes(res)
|
||||
if (!bytes) {
|
||||
fail(new Error(
|
||||
'GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)',
|
||||
))
|
||||
return
|
||||
}
|
||||
if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) {
|
||||
fail(new Error('音频响应为空'))
|
||||
return
|
||||
}
|
||||
succeed(res, bytes as unknown as BodyInit)
|
||||
},
|
||||
onerror(res) {
|
||||
fail(new Error(`音频 CDN 请求失败:${describe(res)}`))
|
||||
},
|
||||
ontimeout() {
|
||||
fail(new Error('音频 CDN 请求超时'))
|
||||
},
|
||||
}) as unknown as { abort: () => void }
|
||||
})
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException('音频请求已取消', 'AbortError')
|
||||
}
|
||||
|
||||
/** 把 GM 的响应头文本解析成 Headers */
|
||||
function parseHeaders(raw: string | undefined): Headers {
|
||||
const headers = new Headers()
|
||||
if (!raw) return headers
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon <= 0) continue
|
||||
const name = line.slice(0, colon).trim()
|
||||
const value = line.slice(colon + 1).trim()
|
||||
if (!name) continue
|
||||
try {
|
||||
headers.append(name, value)
|
||||
} catch {
|
||||
// 个别响应头名字不合规范(比如带空格),跳过即可
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 GM 的状态和响应头拼一个 Response。
|
||||
*
|
||||
* 播放器只读 status / ok / body,不需要还原全部语义。
|
||||
* body 为空时给一条空流:206 是合法状态,但 `new Response(null, {status:206})`
|
||||
* 会抛错(只有 204/304 允许空 body)。
|
||||
*/
|
||||
function makeResponse(res: Tampermonkey.ResponseBase, body: BodyInit | null): Response {
|
||||
return new Response(body ?? new Uint8Array(), {
|
||||
status: res.status || 200,
|
||||
statusText: res.statusText || '',
|
||||
headers: parseHeaders(res.responseHeaders),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 GM 响应里取出完整字节。
|
||||
*
|
||||
* 只认真正的二进制载荷 —— 见文件头关于 responseText 的说明,
|
||||
* 拿不到就返回 null 让上层报错,比悄悄喂坏数据好。
|
||||
*/
|
||||
function toBytes(res: Tampermonkey.Response<unknown>): Uint8Array | null {
|
||||
const raw = (res as AnyResponse).response
|
||||
if (raw instanceof ArrayBuffer) return new Uint8Array(raw)
|
||||
if (raw instanceof Uint8Array) return raw
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeHeaders(init: HeadersInit | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
if (!init) return out
|
||||
if (init instanceof Headers) {
|
||||
init.forEach((value, key) => { out[key] = value })
|
||||
return out
|
||||
}
|
||||
if (Array.isArray(init)) {
|
||||
for (const [key, value] of init) {
|
||||
if (key !== undefined && value !== undefined) out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
return { ...init }
|
||||
}
|
||||
|
||||
function describe(res: Tampermonkey.ErrorResponse | Tampermonkey.ResponseBase): string {
|
||||
if ('error' in res && res.error) return String(res.error)
|
||||
const status = (res as Tampermonkey.ResponseBase).status
|
||||
return status ? `HTTP ${status}` : '网络错误'
|
||||
}
|
||||
Reference in new issue
Block a user