feat: resolve AniList episodes through ZokoAnime

This commit is contained in:
2026-10-25 15:46:35 +01:00
parent 8c2a18df48
commit ab61d83c69
16 changed files with 492 additions and 21 deletions
+139
View File
@@ -0,0 +1,139 @@
import Foundation
enum ZokoError: String, LocalizedError {
case unavailable, transport, protection, malformed
var errorDescription: String? {
switch self {
case .unavailable: "ZokoAnime has no sub episode available for this catalog number. Catalog numbering does not confirm availability."
case .transport: "ZokoAnime HTTPS request failed or timed out. No retry was attempted."
case .protection: "ZokoAnime returned an access/protection page or refused the request."
case .malformed: "ZokoAnime returned unsupported or malformed source data. Private source details are redacted."
}
}
}
// Narrow policy for the demonstrated deployment, not a general cross-origin grant.
enum ZokoResolver {
static let bodyLimit = 2 * 1024 * 1024
static let referer = "Referer: https://zokoanime.video/\r\n"
static func validResource(_ url: URL, playlist: Bool = false) -> Bool {
guard ResolvedSource.validURL(url.absoluteString), url.scheme == "https",
url.port == nil || url.port == 443,
let host = url.host, host == "hls.dramahot.top" || (!playlist && host == "hls1.drama1.cfd") else { return false }
return true
}
private final class NoRedirect: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
}
private static func fetch(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCredentialStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 20
configuration.timeoutIntervalForResource = 25
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
do {
let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse, response.url == request.url else { throw ZokoError.transport }
guard response.expectedContentLength <= bodyLimit else { throw ZokoError.malformed }
if response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503 { throw ZokoError.protection }
guard response.statusCode == 200 else { throw ZokoError.transport }
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < bodyLimit else { throw ZokoError.malformed }
data.append(byte)
}
return (data, response)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled { throw CancellationError() }
if let error = error as? ZokoError { throw error }
throw ZokoError.transport
}
}
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/ani/\(mediaID)/\(episode)/sub")!)
request.timeoutInterval = 20
request.setValue("text/html", forHTTPHeaderField: "Accept")
let (data, response) = try await fetch(request)
guard response.mimeType == "text/html", let page = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
let source = try parse(page: page, mediaID: mediaID, episode: episode)
let title = (try? await Self.title(mediaID: mediaID, episode: episode)) ?? source.title
try Task.checkCancellation()
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
title: title, subtitles: source.subtitles, requirements: source.requirements)
}
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;"#
let regex = try NSRegularExpression(pattern: pattern)
let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page))
guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else {
guard !page.contains("window.__P") else { throw ZokoError.malformed }
let lower = page.lowercased()
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") || lower.contains("captcha") { throw ZokoError.protection }
throw ZokoError.malformed
}
guard let encoded = Data(base64Encoded: String(page[range])), !encoded.isEmpty else { throw ZokoError.malformed }
let key = Array("otaku-embed-v1".utf8)
var decoded = Array(encoded)
for i in decoded.indices { decoded[i] ^= key[i % key.count] }
struct Config: Decodable {
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
let src: String
let subtitles: [Subtitle]
}
let config: Config
do { config = try JSONDecoder().decode(Config.self, from: Data(decoded)) }
catch { throw ZokoError.malformed }
guard let url = URL(string: config.src), validResource(url, playlist: true), url.path.hasSuffix(".m3u8"), config.subtitles.count <= 32 else { throw ZokoError.malformed }
let subtitles = try config.subtitles.map { row -> ResolvedSubtitle in
guard let url = URL(string: row.src), validResource(url, playlist: true), url.path.hasSuffix(".vtt") else { throw ZokoError.malformed }
return ResolvedSubtitle(url: row.src, format: "vtt", language: row.lang, label: row.label,
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
}
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
requirements: .init(zoko: true))
}
static func title(mediaID: String, episode: Int) async throws -> String {
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
var request = URLRequest(url: URL(string: "https://graphql.anilist.co")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){id title{english romaji native}}}", "variables": ["id": Int(mediaID)!]])
let (data, response) = try await fetch(request)
guard response.mimeType == "application/json" else { throw ZokoError.malformed }
struct Metadata: Decodable {
struct Body: Decodable {
struct Media: Decodable {
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
let id: Int; let title: Title
}
let media: Media
enum CodingKeys: String, CodingKey { case media = "Media" }
}
let data: Body
}
let metadata = try JSONDecoder().decode(Metadata.self, from: data).data.media
guard String(metadata.id) == mediaID,
let name = [metadata.title.english, metadata.title.romaji, metadata.title.native].compactMap({ $0 }).first(where: { !$0.isEmpty }),
ResolvedSource.validText(name, limit: 2048) else { throw ZokoError.malformed }
return "\(name) · Episode \(episode) (sub)"
}
}