diff --git a/packages/adapters/src/sites/mteam/subtitles.test.ts b/packages/adapters/src/sites/mteam/subtitles.test.ts index 7fe9a336..80e2e2c3 100644 --- a/packages/adapters/src/sites/mteam/subtitles.test.ts +++ b/packages/adapters/src/sites/mteam/subtitles.test.ts @@ -7,6 +7,7 @@ import { staticSecretResolver } from "../types.ts"; import { createMteamAdapter } from "./adapter.ts"; import { mteamApiBase, mteamDefinition } from "./definition.ts"; import { mteamSearchParams } from "./spider.ts"; +import { parseMteamSubtitles } from "./subtitles.ts"; function adapter(fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise) { return createMteamAdapter( @@ -20,6 +21,87 @@ function adapter(fetch: (input: RequestInfo | URL, init?: RequestInit) => Promis } describe("M-Team v3.2.4 API", () => { + it("下载次数仅返回非负整数,秒与毫秒时间戳解析到同一UTC时刻", () => { + const rows = parseMteamSubtitles( + [ + { id: "1", hits: "12", createdDate: 1_700_000_000 }, + { id: "2", hits: 1.25, createdDate: "1700000000000" }, + { id: "3", hits: -1, createdDate: 1_700_000_000.9 }, + { id: "4", hits: "invalid", createdDate: "99999999999999999" }, + ], + mteamDefinition.domain, + "+08:00", + ); + expect(rows.map((row) => row.downloads)).toEqual([12, null, null, null]); + expect(rows.map((row) => row.uploadedAt)).toEqual([ + "2023-11-14T22:13:20.000Z", + "2023-11-14T22:13:20.000Z", + "2023-11-14T22:13:20.000Z", + null, + ]); + }); + + it("字幕302可先相对跳转再到签名CDN,各跳均不转发API Key或Cookie", async () => { + const mock = createMockFetch({ + "/api/subtitle/genlink": { body: { code: 0, data: "initial-credential" } }, + "/api/subtitle/dlV2": { + status: 302, + headers: { location: "/signed/step?opaque=first-signature", "set-cookie": "session=private" }, + }, + "/signed/step": { status: 302, headers: { location: "https://cdn.example/subtitle?opaque=cdn-signature" } }, + "https://cdn.example/subtitle": { + body: "subtitle data", + headers: { "content-type": "application/octet-stream", "content-disposition": "attachment; filename=demo.srt" }, + }, + }); + const inits: RequestInit[] = []; + const client = adapter(async (input, init) => { + inits.push(init ?? {}); + return mock.fetch(input, init); + }); + const result = await client.downloadSubtitle!("sub-200"); + expect(new TextDecoder().decode(result.bytes)).toBe("subtitle data"); + expect(result.fileName).toBe("demo.srt"); + expect(mock.requests.map((request) => request.url.hostname)).toEqual([ + "subtitles.example", + "subtitles.example", + "subtitles.example", + "cdn.example", + ]); + for (const request of mock.requests.slice(1)) { + expect(request.headers["x-api-key"]).toBeUndefined(); + expect(request.headers.cookie).toBeUndefined(); + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers.referer).toBeUndefined(); + } + for (const init of inits.slice(1)) + expect(init).toMatchObject({ + method: "GET", + redirect: "manual", + credentials: "omit", + referrerPolicy: "no-referrer", + }); + expect(JSON.stringify({ ...result, bytes: undefined })).not.toContain("signature"); + }); + + it("跳转循环与CDN网络错误不暴露任意名称的签名参数", async () => { + const loop = createMockFetch({ + "/api/subtitle/genlink": { body: { code: 0, data: "temporary-secret" } }, + "/api/subtitle/dlV2": { status: 302, headers: { location: "?opaque=loop-secret" } }, + }); + await expect(adapter(loop.fetch).downloadSubtitle!("sub-200")).rejects.toThrow("M-Team 字幕下载跳转无效或次数过多"); + const failed = createMockFetch({ + "/api/subtitle/genlink": { body: { code: 0, data: "temporary-secret" } }, + "/api/subtitle/dlV2": { status: 302, headers: { location: "https://cdn.example/subtitle?opaque=leak-secret" } }, + "https://cdn.example/subtitle": () => { + throw new Error("request https://cdn.example/subtitle?opaque=leak-secret failed"); + }, + }); + await expect(adapter(failed.fetch).downloadSubtitle!("sub-200")).rejects.toMatchObject({ + kind: "network", + message: "M-Team 字幕下载请求失败", + }); + }); it("专用分派同样透传分类和分页元数据,按原始页数判断尾页", async () => { const mock = createMockFetch({ "/api/torrent/search": { diff --git a/packages/adapters/src/sites/mteam/subtitles.ts b/packages/adapters/src/sites/mteam/subtitles.ts index d723440c..9e1ff55e 100644 --- a/packages/adapters/src/sites/mteam/subtitles.ts +++ b/packages/adapters/src/sites/mteam/subtitles.ts @@ -29,8 +29,10 @@ export function parseMteamSubtitles(data: unknown, webUrl: string, timezone: str const torrentId = string(isPlainObject(row.torrent) ? row.torrent.id : row.torrent); const created = row.createdDate ?? row.lastModifiedDate; let uploadedAt: string | null = null; - if (created != null && /^\d+(?:\.\d+)?$/.test(String(created))) { - const date = new Date(Number(created) * 1000); + if (created != null && /^-?\d+(?:\.\d+)?$/.test(String(created))) { + const timestamp = Math.trunc(Number(created)); + // 上游 format_timestamp 以秒为单位;另兼容站点返回的常见13位毫秒值。 + const date = new Date(Math.abs(timestamp) >= 1_000_000_000_000 ? timestamp : timestamp * 1000); if (Number.isFinite(date.getTime())) uploadedAt = date.toISOString(); } else if (typeof created === "string") { uploadedAt = isoWithZoneToUtc(created) ?? parseSiteTime(created, timezone); @@ -44,7 +46,7 @@ export function parseMteamSubtitles(data: unknown, webUrl: string, timezone: str language: string(row.lang ?? row.language), sizeBytes: row.size == null ? null : parseSizeMp(String(row.size)), uploadedAt, - downloads: Number.isFinite(downloads) ? downloads : null, + downloads: Number.isSafeInteger(downloads) && downloads >= 0 ? downloads : null, uploader: string(row.author), torrentId, detailUrl: torrentId @@ -68,6 +70,43 @@ function fileName(headers: Headers): string | null { return /filename="([^"]+)"/i.exec(header)?.[1] ?? /filename=([^;\s]+)/i.exec(header)?.[1] ?? null; } +/** 文件下载逐跳取值;每跳重新构造无凭据GET,CDN签名地址始终只在内存中使用。 */ +async function fetchSubtitleFile(http: SiteHttpContext, initial: URL, signal?: AbortSignal): Promise { + let url = initial; + for (let redirects = 0; ; redirects++) { + let response: Response; + try { + response = await siteFetch(http, url.href, { + method: "GET", + redirect: "manual", + credentials: "omit", + referrerPolicy: "no-referrer", + headers: { accept: "application/octet-stream" }, + ...(signal ? { signal } : {}), + }); + } catch (error) { + if (signal?.aborted) throw error; + // 签名参数未必叫credential/token;不把传输层可能包含完整CDN地址的消息带回调用方。 + throw new SiteError(http.siteId, error instanceof SiteError ? error.kind : "network", "M-Team 字幕下载请求失败", { + ...(error instanceof SiteError && error.httpStatus !== undefined ? { httpStatus: error.httpStatus } : {}), + ...(error instanceof SiteError && error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}), + }); + } + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + await response.body?.cancel().catch(() => {}); + const location = response.headers.get("location"); + if (!location || redirects >= 5) throw new SiteError(http.siteId, "network", "M-Team 字幕下载跳转无效或次数过多"); + try { + const next = new URL(location, url); + if (!["http:", "https:"].includes(next.protocol) || next.username || next.password) + throw new Error("invalid redirect"); + url = next; + } catch { + throw new SiteError(http.siteId, "network", "M-Team 字幕下载跳转地址无效"); + } + } +} + export function createMteamSubtitleClient(options: { http: SiteHttpContext; apiBase: string; @@ -129,10 +168,7 @@ export function createMteamSubtitleClient(options: { throw new SiteError(http.siteId, "parse_error", "M-Team 字幕接口未返回下载凭据"); const url = new URL(`${apiBase.replace(/\/+$/, "")}/api/subtitle/dlV2`); url.searchParams.set("credential", credential); - const response = await siteFetch(http, url.href, { - redirect: "manual", - ...(opts.signal ? { signal: opts.signal } : {}), - }); + const response = await fetchSubtitleFile(http, url, opts.signal); if (!response.ok) { await response.body?.cancel().catch(() => {}); throw new SiteError(