169 lines
6.8 KiB
Swift
169 lines
6.8 KiB
Swift
import Foundation
|
|
|
|
/// 一个探针的测量结果
|
|
public struct GlobalpingProbe: Sendable, Equatable, Identifiable, Codable {
|
|
public var id: String
|
|
public var continent: String
|
|
public var countryCode: String
|
|
public var city: String
|
|
/// 探针所在网络(运营商或机房)
|
|
public var network: String
|
|
/// 往返延迟的平均值,测不到时为 nil
|
|
public var milliseconds: Double?
|
|
/// 丢包百分比
|
|
public var loss: Double?
|
|
}
|
|
|
|
/// 一次全球探针测量
|
|
public struct GlobalpingRun: Sendable, Equatable, Codable {
|
|
public var id: String
|
|
public var target: String
|
|
public var date: Date
|
|
public var probes: [GlobalpingProbe]
|
|
/// 本小时内还剩多少次额度(按调用方 IP 计)
|
|
public var remaining: Int?
|
|
public var limit: Int?
|
|
}
|
|
|
|
/// Globalping 的公开接口:全球社区探针 ping 指定目标。
|
|
///
|
|
/// 这里**不带任何密钥**,匿名调用。额度按调用方 IP 计算(每小时 250 次,一个探针算一次),
|
|
/// 每台 Mac 用自己的额度,不经过我们的服务器,也不消耗我们的额度。
|
|
///
|
|
/// 注意:Globalping 的测量参数与结果是公开的,凭测量 ID 谁都能查到,
|
|
/// 所以只测用户自己填写的域名,不要把用户的公网 IP 发过去。
|
|
public enum GlobalpingClient {
|
|
public enum Failure: Error, Sendable, Equatable {
|
|
/// 超出额度,附带需要等待的秒数
|
|
case rateLimited(seconds: Int?)
|
|
case http(Int)
|
|
case invalidTarget
|
|
case timeout
|
|
}
|
|
|
|
static let base = URL(string: "https://api.globalping.io/v1/measurements")!
|
|
/// 结果通常几秒内就绪,轮询间隔 0.5 秒也在官方限制(每个测量每秒 2 次)之内
|
|
static let pollInterval: Duration = .milliseconds(500)
|
|
static let pollTimeout: Duration = .seconds(30)
|
|
|
|
private static let session: URLSession = {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = 15
|
|
configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"]
|
|
return URLSession(configuration: configuration)
|
|
}()
|
|
|
|
/// 从全球按大洲分散挑 `probes` 个探针 ping 目标
|
|
public static func ping(target: String, probes: Int) async throws -> GlobalpingRun {
|
|
let target = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !target.isEmpty, !target.contains(" "), target.contains(".") else { throw Failure.invalidTarget }
|
|
|
|
var request = URLRequest(url: base)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
// 各大洲自带数量时不能再传总数,否则接口返回 400
|
|
request.httpBody = try JSONEncoder().encode(Request(target: target, locations: distribution(probes)))
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else { throw Failure.http(0) }
|
|
let limits = rateLimits(http)
|
|
if http.statusCode == 429 {
|
|
throw Failure.rateLimited(seconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init))
|
|
}
|
|
guard http.statusCode == 202 || http.statusCode == 200,
|
|
let created = try? JSONDecoder().decode(Created.self, from: data) else {
|
|
throw Failure.http(http.statusCode)
|
|
}
|
|
|
|
let clock = ContinuousClock()
|
|
let deadline = clock.now.advanced(by: pollTimeout)
|
|
while clock.now < deadline {
|
|
try? await Task.sleep(for: pollInterval)
|
|
try Task.checkCancellation()
|
|
guard let (data, response) = try? await session.data(from: base.appendingPathComponent(created.id)),
|
|
(response as? HTTPURLResponse)?.statusCode == 200,
|
|
let measurement = try? JSONDecoder().decode(Measurement.self, from: data) else { continue }
|
|
guard measurement.status != "in-progress" else { continue }
|
|
return GlobalpingRun(id: created.id, target: target, date: Date(),
|
|
probes: measurement.results.enumerated().map { index, item in
|
|
GlobalpingProbe(id: "\(created.id)-\(index)",
|
|
continent: item.probe.continent,
|
|
countryCode: item.probe.country,
|
|
city: item.probe.city,
|
|
network: item.probe.network,
|
|
milliseconds: item.result.stats?.avg,
|
|
loss: item.result.stats?.loss)
|
|
},
|
|
remaining: limits.remaining, limit: limits.limit)
|
|
}
|
|
throw Failure.timeout
|
|
}
|
|
|
|
static func rateLimits(_ response: HTTPURLResponse) -> (limit: Int?, remaining: Int?) {
|
|
(response.value(forHTTPHeaderField: "x-ratelimit-limit").flatMap(Int.init),
|
|
response.value(forHTTPHeaderField: "x-ratelimit-remaining").flatMap(Int.init))
|
|
}
|
|
|
|
/// 按大洲分配探针数,结果覆盖面更均匀;余数补给亚洲、欧洲、北美
|
|
static func distribution(_ probes: Int) -> [Location] {
|
|
let weights: [(String, Double)] = [("AS", 0.3), ("EU", 0.25), ("NA", 0.25), ("SA", 0.08), ("OC", 0.06), ("AF", 0.06)]
|
|
var counts = weights.map { (continent: $0.0, limit: Int((Double(probes) * $0.1).rounded(.down))) }
|
|
var index = 0
|
|
while counts.reduce(0, { $0 + $1.limit }) < probes {
|
|
counts[index % 3].limit += 1
|
|
index += 1
|
|
}
|
|
return counts.filter { $0.limit > 0 }.map { Location(continent: $0.continent, limit: $0.limit) }
|
|
}
|
|
|
|
// MARK: 报文
|
|
|
|
struct Location: Codable, Sendable {
|
|
var continent: String
|
|
var limit: Int
|
|
}
|
|
|
|
private struct Request: Codable {
|
|
var type = "ping"
|
|
var target: String
|
|
var locations: [Location]
|
|
var measurementOptions = Options()
|
|
|
|
struct Options: Codable {
|
|
var packets = 4
|
|
}
|
|
}
|
|
|
|
private struct Created: Codable {
|
|
var id: String
|
|
}
|
|
|
|
private struct Measurement: Codable {
|
|
var status: String
|
|
var results: [Item]
|
|
|
|
struct Item: Codable {
|
|
var probe: Probe
|
|
var result: Result
|
|
}
|
|
|
|
struct Probe: Codable {
|
|
var continent: String
|
|
var country: String
|
|
var city: String
|
|
var network: String
|
|
}
|
|
|
|
struct Result: Codable {
|
|
var status: String?
|
|
var stats: Stats?
|
|
}
|
|
|
|
struct Stats: Codable {
|
|
var min: Double?
|
|
var avg: Double?
|
|
var max: Double?
|
|
var loss: Double?
|
|
}
|
|
}
|
|
}
|