import CryptoKit import CFNetwork import Foundation enum GuangyaAPIError: LocalizedError { case invalidResponse case http(status: Int, message: String) case missingField(String) case noRefreshToken case authorizationExpired var isDeviceAuthorizationPending: Bool { guard case .http(let status, let message) = self, status == 400 else { return false } let normalized = message.lowercased() return normalized.contains("authorization_pending") || normalized.contains("slow_down") } var errorDescription: String? { switch self { case .invalidResponse: return "服务器返回了无法识别的数据" case .http(let status, let message): return "请求失败(\(status)):\(message)" case .missingField(let field): return "响应缺少字段:\(field)" case .noRefreshToken: return "没有可用的刷新令牌" case .authorizationExpired: return "登录授权已失效" } } } final class GuangyaAPI: @unchecked Sendable { static let accountBase = URL(string: "https://account.guangyapan.com")! static let apiBase = URL(string: "https://api.guangyapan.com")! static let clientID = "aMe-8VSlkrbQXpUR" private let session: URLSession private(set) var accessToken: String private(set) var refreshTokenValue: String? private var tokenExpiresAt: Date? let deviceID: String init(accessToken: String = "", refreshToken: String? = nil, deviceID: String = GuangyaAPI.generateDeviceID()) { self.accessToken = accessToken self.refreshTokenValue = refreshToken self.deviceID = deviceID let configuration = URLSessionConfiguration.default // Cloud listing/detail calls can be slow on congested networks. Let the // system wait for a route and retry transient failures below instead of // surfacing the default 15-second timeout to the user. configuration.timeoutIntervalForRequest = 45 configuration.timeoutIntervalForResource = 120 configuration.waitsForConnectivity = true configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.httpMaximumConnectionsPerHost = 20 // Keep the app's long-lived API session eligible for HTTP/2 connection // reuse while seconds-transfer sends its request sequence. configuration.httpShouldUsePipelining = true configuration.httpAdditionalHeaders = [ "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", "did": deviceID, "dt": "4", "Origin": "https://www.guangyapan.com", "Referer": "https://www.guangyapan.com/", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36" ] session = URLSession(configuration: configuration) } func updateTokens(from value: JSONValue) -> AuthTokens? { guard let access = value.firstStringDeep(["access_token", "accessToken"]) else { return nil } accessToken = access refreshTokenValue = value.firstStringDeep(["refresh_token", "refreshToken"]) ?? refreshTokenValue let expires = value.firstIntDeep(["expires_in", "expiresIn"]).map(Double.init) tokenExpiresAt = expires.map { Date().addingTimeInterval($0) } return AuthTokens(accessToken: access, refreshToken: refreshTokenValue, expiresIn: expires) } func prepareSession() async throws { // A session with an access token starts optimistically and only refreshes // after an actual authorization failure. With no access token, the stored // refresh token is the only way to restore the session. if accessToken.isEmpty { guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } try await refreshOrThrowAuthorizationExpired() } } func clearTokens() { accessToken = "" refreshTokenValue = nil tokenExpiresAt = nil } private func isInvalidRefreshToken(_ error: Error) -> Bool { guard let apiError = error as? GuangyaAPIError else { return false } switch apiError { case .noRefreshToken: return true case .http(_, let message): let text = message.lowercased() return text.contains("invalid_grant") || text.contains("invalid refresh") || text.contains("refresh_token") || text.contains("refresh token") || text.contains("token expired") || text.contains("refresh token已") default: return false } } private func refreshOrThrowAuthorizationExpired() async throws { do { _ = try await refreshToken() } catch { if isInvalidRefreshToken(error) { clearTokens() throw GuangyaAPIError.authorizationExpired } throw error } } // MARK: Authentication func loginSMSInit(phoneNumber: String, captchaToken: String? = nil) async throws -> JSONValue { var body: [String: JSONValue] = [ "client_id": .string(Self.clientID), "action": .string("POST:/v1/auth/verification"), "device_id": .string(deviceID), "meta": .object(["phone_number": .string(phoneNumber)]) ] if let captchaToken { body["captcha_token"] = .string(captchaToken) } return try await accountRequest("/v1/shield/captcha/init", body: .object(body)) } func loginSMSSend(phoneNumber: String, captchaToken: String, target: String = "ANY") async throws -> JSONValue { try await accountRequest("/v1/auth/verification", body: .object([ "phone_number": .string(phoneNumber), "target": .string(target), "client_id": .string(Self.clientID) ]), extraHeaders: ["x-captcha-token": captchaToken]) } func loginSMSVerify(verificationID: String, verificationCode: String) async throws -> JSONValue { try await accountRequest("/v1/auth/verification/verify", body: .object([ "verification_id": .string(verificationID), "verification_code": .string(verificationCode), "client_id": .string(Self.clientID) ])) } func loginSMSSignIn(code: String, verificationToken: String, username: String, captchaToken: String) async throws -> JSONValue { let result = try await accountRequest("/v1/auth/signin", body: .object([ "verification_code": .string(code), "verification_token": .string(verificationToken), "username": .string(username), "client_id": .string(Self.clientID) ]), extraHeaders: ["x-captcha-token": captchaToken]) _ = updateTokens(from: result) return result } /// OAuth device authorization flow used by the web client QR login. func loginQRInit() async throws -> JSONValue { try await accountRequest("/v1/auth/device/code", body: .object([ "scope": .string("user"), "client_id": .string(Self.clientID) ])) } func loginQRPoll(token: String) async throws -> JSONValue { let result = try await accountRequest("/v1/auth/token", body: .object([ "grant_type": .string("urn:ietf:params:oauth:grant-type:device_code"), "device_code": .string(token), "client_id": .string(Self.clientID) ])) _ = updateTokens(from: result) return result } func refreshToken(_ token: String? = nil) async throws -> JSONValue { guard let token = token ?? refreshTokenValue else { throw GuangyaAPIError.noRefreshToken } let result = try await accountRequest("/v1/auth/token", body: .object([ "client_id": .string(Self.clientID), "grant_type": .string("refresh_token"), "refresh_token": .string(token) ]), extraHeaders: ["x-action": "401"]) _ = updateTokens(from: result) return result } func userInfo() async throws -> JSONValue { try await accountRequest("/v1/user/me", body: nil, authenticated: true) } // MARK: Cloud download, file system, and sharing func cloudTaskList(page: Int = 0, pageSize: Int = 50, status: [Int] = [0, 1, 3, 4]) async throws -> JSONValue { try await apiRequest("/nd.bizcloudcollection.s/v1/list_task", body: object(("page", page), ("pageSize", pageSize), ("status", status))) } func cloudResolveURL(_ url: String) async throws -> JSONValue { try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_res", body: object(("url", url))) } func cloudResolveTorrent(data: Data, filename: String = "file.torrent") async throws -> JSONValue { try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_torrent", method: "POST", multipart: [("torrent", filename, "application/octet-stream", data)]) } func cloudCreateTask(url: String, parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizcloudcollection.s/v1/create_task", body: object(("url", url), ("parentId", parentID ?? ""))) } func downloadURL(fileID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_res_download_url", body: .object(["fileId": .string(fileID)])) } func taskStatus(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_task_status", body: object(("taskId", taskID))) } func fsFiles(parentID: String? = nil, page: Int = 0, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0, fileTypes: [Int]? = nil, resType: Int? = nil, dirType: Int? = nil, needPlayRecord: Bool = false) async throws -> JSONValue { var body: [String: JSONValue] = ["parentId": .string(parentID ?? ""), "page": .number(Double(page)), "pageSize": .number(Double(pageSize)), "orderBy": .number(Double(orderBy)), "sortType": .number(Double(sortType))] if let fileTypes { body["fileTypes"] = .array(fileTypes.map { .number(Double($0)) }) } if let resType { body["resType"] = .number(Double(resType)) } if let dirType { body["dirType"] = .number(Double(dirType)) } if needPlayRecord { body["needPlayRecord"] = .bool(true) } return try await apiRequest("/userres/v1/file/get_file_list", body: .object(body)) } func recentViewed(pageSize: Int = 100, cursor: String = "") async throws -> JSONValue { try await apiRequest("/userres/v1/get_user_action", body: object(("cursor", cursor), ("pageSize", pageSize))) } func recentRestored(pageSize: Int = 100) async throws -> JSONValue { try await apiRequest("/userres/v1/get_restore_list", body: object(("pageSize", pageSize), ("orderBy", 2), ("sortType", 1))) } func fsImageList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [1], resType: 1) } func fsVideoList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [2], resType: 1) } func fsAudioList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true) } func fsDocumentList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [4], resType: 1) } func fsRecycleFiles() async throws -> JSONValue { try await fsFiles(orderBy: 10, dirType: 4) } func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue { var body = ["dirName": JSONValue.string(name), "parentId": apiIDOrEmpty(parentID)] if failIfNameExist { body["failIfNameExist"] = .bool(true) } return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body), allowedCodes: failIfNameExist ? [159] : []) } func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) } func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) } func fsDelete(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_file", body: object(("fileIds", apiIDs(fileIDs)))) } func fsRecycle(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/recycle_file", body: object(("fileIds", apiIDs(fileIDs)))) } func fsClearRecycleBin() async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/clear_recycle_bin") } func fsRename(fileID: String, newName: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/rename", body: object(("fileId", apiID(fileID)), ("newName", newName))) } func fsDetail(fileID: String) async throws -> JSONValue { // Build the payload explicitly so the 19-digit resource ID can never pass // through the Any/NSNumber conversion path and lose its JSON string type. try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: .object(["fileId": .string(fileID)])) } func vodDownloadURL(fileID: String, gcid: String) async throws -> JSONValue { try await apiRequest( "/userres/v1/file/get_vod_download_url", body: .object(["fileId": .string(fileID), "gcid": .string(gcid)]) ) } func shareCreate(fileIDs: [String], title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/share_file", body: object(("fileIds", apiIDs(fileIDs)), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) } func shareUserList(page: Int = 0, pageSize: Int = 50) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_list", body: object(("page", page), ("pageSize", pageSize), ("orderType", 1), ("sortType", 1))) } func shareUpdate(shareID: String, title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/update_share", body: object(("id", shareID), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) } func shareDelete(ids: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/delete_share", body: object(("ids", ids))) } func shareRestore(accessToken: String, fileIDs: [String], parentID: String = "") async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/restore_share", body: object(("accessToken", accessToken), ("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID.isEmpty ? nil : parentID)))) } func shareDownloadURL(fileID: String, accessToken: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_download_url", body: object(("fileId", apiID(fileID)), ("accessToken", accessToken))) } func shareFilesSize(accessToken: String, fileIDs: [String], download: Bool = true) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_files_size", body: object(("accessToken", accessToken), ("fileIds", apiIDs(fileIDs)), ("download", download))) } func shareSummary(shareID: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_summary", body: object(("shareId", shareID))) } func shareAccessToken(shareID: String, code: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_access_token", body: object(("shareId", shareID), ("code", code))) } func shareFilesList(accessToken: String, parentID: String = "", page: Int = 1, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_page_files_list", body: object(("accessToken", accessToken), ("parentId", apiIDOrEmpty(parentID.isEmpty ? nil : parentID)), ("page", page), ("pageSize", pageSize), ("orderBy", orderBy), ("sortType", sortType))) } // MARK: Upload func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue { var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))] if let md5 { res["md5"] = .string(Self.base64MD5(md5)) } return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156]) } /// The web client's seconds-transfer workflow probes a hexadecimal MD5 with /// capacity 1. A 156 business code means the file was linked immediately. func flashTransferToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String) async throws -> JSONValue { let normalizedMD5 = md5.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let res: [String: JSONValue] = ["md5": .string(normalizedMD5), "fileSize": .number(Double(fileSize))] return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(1), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156]) } /// Guangya GCID exports use the same capacity-1 instant-transfer endpoint /// as MD5 exports, with gcid placed directly in res. func flashTransferGCIDToken(name: String, fileSize: Int64, parentID: String? = nil, gcid: String) async throws -> JSONValue { let normalizedGCID = gcid.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let res: [String: JSONValue] = ["gcid": .string(normalizedGCID), "fileSize": .number(Double(fileSize))] return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(1), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156]) } func deleteUploadTask(taskIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_upload_task", body: object(("taskIds", taskIDs))) } func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) } func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID)), allowedCodes: [145, 146, 155, 163]) } func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "", year: Int? = nil) async throws -> JSONValue { let endpoint = mediaKind == .movie ? "movie" : (mediaKind == .tv ? "tv" : "multi") var components = URLComponents(string: "https://api.themoviedb.org/3/search/\(endpoint)")! components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "region", value: "CN"), URLQueryItem(name: "include_adult", value: "false")] if let year { switch mediaKind { case .movie: components.queryItems?.append(URLQueryItem(name: "primary_release_year", value: String(year))) case .tv: components.queryItems?.append(URLQueryItem(name: "first_air_date_year", value: String(year))) case .automatic: break } } return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort) } func tmdbDetails(id: Int, mediaKind: TMDBMediaKind, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { let endpoint = mediaKind == .tv ? "tv" : "movie" var components = URLComponents(string: "https://api.themoviedb.org/3/\(endpoint)/\(id)")! components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images,external_ids,translations"), URLQueryItem(name: "include_image_language", value: "zh-CN,zh,null,en")] return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort) } func tmdbEpisodeDetails(seriesID: Int, season: Int, episode: Int, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { var components = URLComponents(string: "https://api.themoviedb.org/3/tv/\(seriesID)/season/\(season)/episode/\(episode)")! components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images,translations"), URLQueryItem(name: "include_image_language", value: "zh-CN,zh,null,en")] return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort) } func tmdbImage(path: String, size: String = "original", apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> Data { guard let url = URL(string: "https://image.tmdb.org/t/p/\(size)\(path)") else { throw GuangyaAPIError.invalidResponse } return try await tmdbDataRequest(url, proxyHost: proxyHost, proxyPort: proxyPort) } private func tmdbJSONRequest(_ url: URL, proxyHost: String, proxyPort: String) async throws -> JSONValue { let data = try await tmdbDataRequest(url, proxyHost: proxyHost, proxyPort: proxyPort) return try JSONDecoder().decode(JSONValue.self, from: data) } private func tmdbDataRequest(_ url: URL, proxyHost: String, proxyPort: String) async throws -> Data { var request = URLRequest(url: url); request.httpMethod = "GET"; request.timeoutInterval = 45; request.setValue("application/json", forHTTPHeaderField: "Accept") let host = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "^https?://", with: "", options: .regularExpression).trimmingCharacters(in: CharacterSet(charactersIn: "/")) let port = Int(proxyPort).flatMap { (1...65535).contains($0) ? $0 : nil } func fetch(useProxy: Bool) async throws -> Data { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 45; config.timeoutIntervalForResource = 120 config.waitsForConnectivity = true; config.requestCachePolicy = .reloadIgnoringLocalCacheData if useProxy, let port { config.connectionProxyDictionary = [kCFNetworkProxiesHTTPEnable as String: true, kCFNetworkProxiesHTTPProxy as String: host, kCFNetworkProxiesHTTPPort as String: port, kCFNetworkProxiesHTTPSEnable as String: true, kCFNetworkProxiesHTTPSProxy as String: host, kCFNetworkProxiesHTTPSPort as String: port] } let (data, response) = try await URLSession(configuration: config).data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") } return data } guard !host.isEmpty, port != nil else { return try await fetch(useProxy: false) } do { return try await fetch(useProxy: true) } catch { // Some local HTTP proxies intermittently fail the CONNECT/TLS tunnel. // TMDB is safe to retry directly when that specific transport fails. let message = error.localizedDescription.lowercased() let nsError = error as NSError let isTLSFailure = nsError.domain == NSURLErrorDomain && [NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid].contains(nsError.code) || message.contains("tls") || message.contains("secure connection") guard isTLSFailure else { throw error } return try await fetch(useProxy: false) } } func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue { let values = try Data(contentsOf: url) let name = url.lastPathComponent if values.count < 1024 * 1024 { let digest = Data(Insecure.MD5.hash(data: values)).base64EncodedString() let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID, md5: digest) guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") } return try await uploadInfo(taskID: taskID) } let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID) guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") } let canFlash = try await checkCanFlashUpload(taskID: taskID, gcid: Self.calculateGCID(data: values)) if canFlash["data"]?["canFlashUpload"]?.boolValue == true { return try await uploadInfo(taskID: taskID) } _ = try await cdnUpload(data: values, tokenData: token["data"] ?? .null, contentType: contentType, chunkSize: chunkSize) return try await uploadInfo(taskID: taskID) } // MARK: Low-level requests and OSS signing private func accountRequest(_ path: String, method: String = "POST", body: JSONValue?, extraHeaders: [String: String] = [:], authenticated: Bool = false) async throws -> JSONValue { var headers = accountHeaders() headers.merge(extraHeaders) { _, new in new } if authenticated || !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" } return try await request(Self.accountBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, refreshOnUnauthorized: false) } private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil, allowedCodes: Set = []) async throws -> JSONValue { if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { try await refreshOrThrowAuthorizationExpired() } var headers = ["traceparent": Self.generateTraceparent()] if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" } return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart, allowedCodes: allowedCodes) } private func publicPost(_ path: String, body: JSONValue) async throws -> JSONValue { try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false) } private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true, retryCount: Int = 2, allowedCodes: Set = []) async throws -> JSONValue { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = multipart == nil ? 45 : 120 headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } if let multipart { let boundary = "Boundary-\(UUID().uuidString)" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = Self.multipartBody(parts: multipart, boundary: boundary) } else if let body { request.httpBody = try JSONEncoder().encode(body) } let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { guard retryCount > 0, Self.isTransientNetworkError(error) else { throw error } try await Task.sleep(for: .seconds(Double(3 - retryCount) * 0.9 + Double.random(in: 0.15...0.55))) return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1, allowedCodes: allowedCodes) } guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse } if http.statusCode == 401, refreshOnUnauthorized { guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } do { try await refreshOrThrowAuthorizationExpired() } catch { // Only an explicitly invalid refresh token ends the local session. // Transport failures and temporary account-service errors remain visible. throw error } return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false, allowedCodes: allowedCodes) } guard (200..<300).contains(http.statusCode) else { if retryCount > 0, [408, 429, 500, 502, 503, 504].contains(http.statusCode) { let retryAfter = Double(http.value(forHTTPHeaderField: "Retry-After") ?? "") ?? Double(3 - retryCount) try await Task.sleep(for: .seconds(max(0.8, min(retryAfter + Double.random(in: 0.1...0.5), 15)))) return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1, allowedCodes: allowedCodes) } let message: String if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { message = decoded.firstStringDeep(["message", "msg", "error_description", "error"]) ?? String(data: data, encoding: .utf8) ?? "未知错误" } else { message = String(data: data, encoding: .utf8) ?? "未知错误" } throw GuangyaAPIError.http(status: http.statusCode, message: message) } if data.isEmpty { return .object([:]) } if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { if let code = decoded.firstIntDeep(["code"]), code != 0, !allowedCodes.contains(code) { // Some API gateways put an expired-token 401 in the JSON body // while keeping the HTTP response status at 200. // The API uses 117 for an invalid or expired bearer token on // some resource endpoints, while other endpoints use 401. if [401, 117].contains(code), refreshOnUnauthorized { guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } do { try await refreshOrThrowAuthorizationExpired() } catch { throw error } return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false, allowedCodes: allowedCodes) } throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误") } return decoded } return .string(String(data: data, encoding: .utf8) ?? "") } private func accountHeaders() -> [String: String] { ["accept": "*/*", "content-type": "application/json", "origin": "https://www.guangyapan.com", "referer": "https://www.guangyapan.com/", "x-client-id": Self.clientID, "x-client-version": "0.0.1", "x-device-id": deviceID, "x-device-model": "chrome%2F147.0.0.0", "x-device-name": "PC-Chrome", "x-device-sign": "wdi10.\(deviceID)\(Self.randomHex(16))", "x-net-work-type": "NONE", "x-os-version": "MacIntel", "x-platform-version": "1", "x-protocol-version": "301", "x-provider-name": "NONE", "x-sdk-version": "9.0.2"] } private func cdnUpload(data: Data, tokenData: JSONValue, contentType: String, chunkSize: Int) async throws -> String { guard let creds = tokenData["creds"], let key = creds.firstString(["accessKeyID", "accessKeyId"]), let secret = creds.firstString(["secretAccessKey"]), let sessionToken = creds.firstString(["sessionToken"]), let endpoint = tokenData.firstString(["fullEndPoint", "fullEndpoint"]), let bucket = tokenData.firstString(["bucketName"]), let objectPath = tokenData.firstString(["objectPath"]) else { throw GuangyaAPIError.missingField("OSS token data") } let objectURL = "\(endpoint)/\(objectPath)" let uploadResponse = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, subResources: ["uploads": ""]) guard let uploadID = Self.xmlValue(uploadResponse.body, tag: "UploadId") else { throw GuangyaAPIError.missingField("UploadId") } var parts: [(Int, String)] = [] var offset = 0 var number = 1 while offset < data.count { let end = min(offset + chunkSize, data.count) let chunk = data.subdata(in: offset.." + parts.map { "\($0.0)\"\($0.1)\"" }.joined() + "" let result = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, content: Data(xml.utf8), contentType: "application/xml", contentMD5: Data(Insecure.MD5.hash(data: Data(xml.utf8))).base64EncodedString(), subResources: ["uploadId": uploadID]) return Self.xmlValue(result.body, tag: "ETag") ?? "" } private func ossRequest(method: String, url: String, bucket: String, objectKey: String, accessKeyID: String, secret: String, sessionToken: String, content: Data = Data(), contentType: String = "", contentMD5: String = "", subResources: [String: String] = [:]) async throws -> (body: String, headers: [String: String]) { let date = Self.httpDate() var canonical = [method.uppercased(), contentMD5, contentType, date, "x-oss-date:\(date)", "x-oss-security-token:\(sessionToken.trimmingCharacters(in: .whitespacesAndNewlines))", "/\(bucket)/\(objectKey)"] if !subResources.isEmpty { canonical[6] += "?" + subResources.keys.sorted().map { key in let value = subResources[key] ?? ""; return value.isEmpty ? key : "\(key)=\(value)" }.joined(separator: "&") } let signature = Data(HMAC.authenticationCode(for: Data(canonical.joined(separator: "\n").utf8), using: SymmetricKey(data: Data(secret.utf8)))).base64EncodedString() var request = URLRequest(url: URL(string: url)!) request.httpMethod = method request.httpBody = content request.setValue("OSS \(accessKeyID):\(signature)", forHTTPHeaderField: "Authorization") request.setValue(date, forHTTPHeaderField: "x-oss-date") request.setValue(sessionToken, forHTTPHeaderField: "x-oss-security-token") if !contentType.isEmpty { request.setValue(contentType, forHTTPHeaderField: "Content-Type") } if !contentMD5.isEmpty { request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") } subResources.forEach { request.url = URL(string: "\(request.url!.absoluteString)\($0.key == subResources.keys.sorted().first ? "?" : "&")\($0.key)=\($0.value)") } let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse } guard (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: http.statusCode, message: String(data: data, encoding: .utf8) ?? "OSS 请求失败") } return (String(data: data, encoding: .utf8) ?? "", http.allHeaderFields.reduce(into: [:]) { result, item in result[String(describing: item.key).lowercased()] = String(describing: item.value) }) } private static func isTransientNetworkError(_ error: Error) -> Bool { let nsError = error as NSError guard nsError.domain == NSURLErrorDomain else { return false } return [NSURLErrorTimedOut, NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed, NSURLErrorInternationalRoamingOff].contains(nsError.code) } private static func multipartBody(parts: [(String, String, String, Data)], boundary: String) -> Data { var body = Data() for (name, filename, contentType, data) in parts { body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\nContent-Type: \(contentType)\r\n\r\n".utf8)); body.append(data); body.append(Data("\r\n".utf8)) } body.append(Data("--\(boundary)--\r\n".utf8)); return body } private static func xmlValue(_ xml: String, tag: String) -> String? { let pattern = "<\(tag)>(.*?)"; guard let range = xml.range(of: pattern, options: .regularExpression) else { return nil }; let value = String(xml[range]); return value.replacingOccurrences(of: "<\(tag)>", with: "").replacingOccurrences(of: "", with: "") } private func apiID(_ value: String) -> JSONValue { // Guangya IDs are decimal strings. Never encode them as Double: 19-digit // IDs exceed IEEE-754's exact integer range and become invalid parameters. .string(value) } private func apiIDOrEmpty(_ value: String?) -> JSONValue { guard let value, !value.isEmpty else { return .string("") } return apiID(value) } private func apiIDs(_ values: [String]) -> [JSONValue] { values.map(apiID) } private func object(_ values: [String: Any]) -> JSONValue { .object(values.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) }) } private func object(_ values: (String, Any)...) -> JSONValue { object(values.reduce(into: [:]) { $0[$1.0] = JSONValue(any: $1.1) }) } private static func generateDeviceID() -> String { Insecure.MD5.hash(data: Data((0..<16).map { _ in UInt8.random(in: 0...255) })).map { String(format: "%02x", $0) }.joined() } private static func randomHex(_ count: Int) -> String { (0.. String { "00-\(randomHex(16))-\(randomHex(8))-01" } private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) } private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset.. String { let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) guard normalized.range(of: "^[A-Fa-f0-9]{32}$", options: .regularExpression) != nil else { return normalized } var bytes = Data() var index = normalized.startIndex while index < normalized.endIndex { let next = normalized.index(index, offsetBy: 2) guard let byte = UInt8(normalized[index..