├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── V2exAPI │ ├── Models │ ├── V2Response.swift │ ├── V2Token.swift │ ├── V2Notification.swift │ ├── V2Comment.swift │ ├── V2Topic.swift │ ├── V2Node.swift │ └── V2Member.swift │ └── V2exAPI.swift ├── V2exAPI.podspec ├── LICENSE ├── Package.swift ├── Tests └── V2exAPITests │ └── V2exAPITests.swift ├── README.md └── README_EN.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Response.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by isaced on 2022/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// V2EX API Response 11 | public struct V2Response: Decodable { 12 | public let success: Bool 13 | public let message: String? 14 | public let result: T 15 | } 16 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Token.swift: -------------------------------------------------------------------------------- 1 | // 2 | // V2Token.swift 3 | // 4 | // 5 | // Created by isaced on 2022/8/7. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | Token 12 | */ 13 | public struct V2Token: Decodable, Equatable, Hashable { 14 | public let token, scope: String? 15 | public let expiration, goodForDays, totalUsed, lastUsed: Int? 16 | public let created: Int? 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case token, scope, expiration 20 | case goodForDays = "good_for_days" 21 | case totalUsed = "total_used" 22 | case lastUsed = "last_used" 23 | case created 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Notification.swift: -------------------------------------------------------------------------------- 1 | // 2 | // V2Notification.swift 3 | // 4 | // 5 | // Created by isaced on 2022/8/7. 6 | // 7 | 8 | import Foundation 9 | 10 | /** 11 | 通知 12 | */ 13 | public struct V2Notification: Decodable, Identifiable, Hashable { 14 | public let id, memberID, forMemberID: Int? 15 | public let text, payload, payloadRendered: String? 16 | public let created: Int? 17 | public let member: V2Member? 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case id 21 | case memberID = "member_id" 22 | case forMemberID = "for_member_id" 23 | case text, payload 24 | case payloadRendered = "payload_rendered" 25 | case created, member 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Comment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // V2Comment.swift 3 | // 4 | // 5 | // Created by isaced on 2022/7/31. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct V2Comment: Identifiable, Decodable, Hashable { 11 | 12 | public let id: Int 13 | public let content: String 14 | public let contentRendered: String 15 | public let created: Int 16 | public let member: V2Member 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case id, content 20 | case contentRendered = "content_rendered" 21 | case created, member 22 | } 23 | 24 | public init(id: Int, content: String, contentRendered: String, created: Int, member: V2Member) { 25 | self.id = id 26 | self.content = content 27 | self.contentRendered = contentRendered 28 | self.created = created 29 | self.member = member 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /V2exAPI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | 3 | spec.name = "V2exAPI" 4 | spec.version = "1.0.0" 5 | spec.summary = "V2ex API 的 Swift 封装,支持 iOS/macOS" 6 | 7 | spec.description = <<-DESC 8 | V2ex API 的 Swift 封装,支持 iOS/macOS,支持 V1/V2 API。 9 | DESC 10 | 11 | spec.homepage = "https://github.com/isaced/V2exAPI" 12 | 13 | spec.license = { :type => "MIT", :file => "LICENSE" } 14 | 15 | spec.author = { "isaced" => "isaced@163.com" } 16 | 17 | spec.ios.deployment_target = "15.0" 18 | spec.osx.deployment_target = "12.0" 19 | spec.watchos.deployment_target = "8.0" 20 | spec.tvos.deployment_target = "15.0" 21 | 22 | spec.source = { :git => "https://github.com/isaced/V2exAPI.git", :tag => "#{spec.version}" } 23 | 24 | spec.source_files = ["Sources/**/*.swift"] 25 | 26 | spec.swift_versions = ['5.0'] 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 isaced 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "V2exAPI", 8 | platforms: [ 9 | .iOS(.v15), 10 | .macOS(.v12), 11 | .tvOS(.v15), 12 | .watchOS(.v8), 13 | ], 14 | products: [ 15 | // Products define the executables and libraries a package produces, and make them visible to other packages. 16 | .library( 17 | name: "V2exAPI", 18 | targets: ["V2exAPI"]) 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | // .package(url: /* package url */, from: "1.0.0"), 23 | ], 24 | targets: [ 25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 26 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 27 | .target( 28 | name: "V2exAPI", 29 | dependencies: []), 30 | .testTarget( 31 | name: "V2exAPITests", 32 | dependencies: ["V2exAPI"]), 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /Tests/V2exAPITests/V2exAPITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import V2exAPI 4 | 5 | final class V2exAPITests: XCTestCase { 6 | 7 | func testAPI() async throws { 8 | let v2ex = V2exAPI() 9 | 10 | let nodes = try? await v2ex.nodesList() 11 | XCTAssertNotNil(nodes) 12 | 13 | let latest = try await v2ex.latestTopics() 14 | XCTAssertNotNil(latest) 15 | 16 | let hot = try await v2ex.hotTopics() 17 | XCTAssertNotNil(hot) 18 | 19 | let nodeDetail = try await v2ex.nodesShow(name: "swift") 20 | XCTAssertNotNil(nodeDetail) 21 | 22 | let member = try await v2ex.memberShow(username: "isaced") 23 | XCTAssertNotNil(member) 24 | 25 | let repliesAll = try await v2ex.repliesAll(topicId: 883252) 26 | XCTAssertNotNil(repliesAll) 27 | 28 | let topics = try await v2ex.topics(nodeName: "apple") 29 | XCTAssertNotNil(topics) 30 | 31 | // --- V2 --- 32 | 33 | // let topics = try await v2ex.topics(nodeName: "swift") 34 | // XCTAssertNotNil(topics) 35 | // 36 | // let topic = try await v2ex.topic(topicId: 870607) 37 | // XCTAssertNotNil(topic) 38 | // 39 | // let node = try await v2ex.getNode(nodeName: "swift") 40 | // XCTAssertNotNil(node) 41 | // 42 | // let notifications = try await v2ex.notifications() 43 | // XCTAssertNotNil(notifications) 44 | // 45 | // let delNotifications = try await v2ex.deleteNotification(notificationId: 5846288) 46 | // XCTAssertNotNil(delNotifications) 47 | // 48 | // let memberMe = try await v2ex.member() 49 | // XCTAssertNotNil(memberMe) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Topic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by isaced on 2022/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// 话题 11 | public struct V2Topic: Identifiable, Decodable, Hashable { 12 | 13 | public let id: Int 14 | public let node: V2Node? 15 | public let member: V2Member? 16 | public let lastReplyBy: String? 17 | public let lastTouched: Int? 18 | public let title: String? 19 | public let url: String? 20 | public let created: Int? 21 | public let deleted: Int? 22 | public let content: String? 23 | public let contentRendered: String? 24 | public let lastModified: Int? 25 | public let replies: Int? 26 | 27 | public init( 28 | id: Int, 29 | node: V2Node? = nil, 30 | member: V2Member? = nil, 31 | lastReplyBy: String? = nil, 32 | lastTouched: Int? = nil, 33 | title: String? = nil, 34 | url: String? = nil, 35 | created: Int? = nil, 36 | deleted: Int? = nil, 37 | content: String? = nil, 38 | contentRendered: String? = nil, 39 | lastModified: Int? = nil, 40 | replies: Int? = nil 41 | ) { 42 | self.id = id 43 | self.node = node 44 | self.member = member 45 | self.lastReplyBy = lastReplyBy 46 | self.lastTouched = lastTouched 47 | self.title = title 48 | self.url = url 49 | self.created = created 50 | self.deleted = deleted 51 | self.content = content 52 | self.contentRendered = contentRendered 53 | self.lastModified = lastModified 54 | self.replies = replies 55 | } 56 | 57 | enum CodingKeys: String, CodingKey { 58 | case node, member 59 | case lastReplyBy = "last_reply_by" 60 | case lastTouched = "last_touched" 61 | case title, url, created, deleted, content 62 | case contentRendered = "content_rendered" 63 | case lastModified = "last_modified" 64 | case replies, id 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Node.swift: -------------------------------------------------------------------------------- 1 | // 2 | // V2Node.swift 3 | // 4 | // 5 | // Created by isaced on 2022/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// 节点 11 | public struct V2Node: Identifiable, Decodable, Hashable { 12 | 13 | public let name: String 14 | public let title: String? 15 | public var id: Int? 16 | public let avatarLarge: String? 17 | public let avatarNormal: String? 18 | public let url: String? 19 | public let topics: Int? 20 | public let footer: String? 21 | public let header: String? 22 | public let titleAlternative: String? 23 | public let avatarMini: String? 24 | public let stars: Int? 25 | public let aliases: [String]? 26 | public let root: Bool? 27 | public let parentNodeName: String? 28 | 29 | public init( 30 | name: String, avatarLarge: String?, avatarNormal: String?, title: String?, url: String?, 31 | topics: Int?, footer: String?, header: String?, titleAlternative: String?, avatarMini: String?, 32 | stars: Int?, aliases: [String]?, root: Bool?, id: Int?, parentNodeName: String? 33 | ) { 34 | self.name = name 35 | self.avatarLarge = avatarLarge 36 | self.avatarNormal = avatarNormal 37 | self.title = title 38 | self.url = url 39 | self.topics = topics 40 | self.footer = footer 41 | self.header = header 42 | self.titleAlternative = titleAlternative 43 | self.avatarMini = avatarMini 44 | self.stars = stars 45 | self.aliases = aliases 46 | self.root = root 47 | self.id = id 48 | self.parentNodeName = parentNodeName 49 | } 50 | 51 | enum CodingKeys: String, CodingKey { 52 | case name, stars, aliases, root, id, title, url, topics, footer, header 53 | case avatarLarge = "avatar_large" 54 | case avatarNormal = "avatar_normal" 55 | case titleAlternative = "title_alternative" 56 | case avatarMini = "avatar_mini" 57 | case parentNodeName = "parent_node_name" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/V2exAPI/Models/V2Member.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by isaced on 2022/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// 会员 11 | public struct V2Member : Decodable, Hashable { 12 | 13 | public var id: Int? 14 | public var username: String? 15 | public var url: String? 16 | public var website: String? 17 | public var twitter: String? 18 | public var psn: String? 19 | public var github: String? 20 | public var btc: String? 21 | public var location: String? 22 | public var tagline: String? 23 | public var bio: String? 24 | public var avatar: String? 25 | public var avatarMini: String? 26 | public var avatarNormal: String? 27 | public var avatarLarge: String? 28 | public var created: Int? 29 | public var lastModified: Int? 30 | 31 | public init(id: Int? = nil, username: String? = nil, url: String? = nil, website: String? = nil, twitter: String? = nil, psn: String? = nil, github: String? = nil, btc: String? = nil, location: String? = nil, tagline: String? = nil, bio: String? = nil, avatar: String? = nil, avatarMini: String? = nil, avatarNormal: String? = nil, avatarLarge: String? = nil, created: Int? = nil, lastModified: Int? = nil) { 32 | self.id = id 33 | self.username = username 34 | self.url = url 35 | self.website = website 36 | self.twitter = twitter 37 | self.psn = psn 38 | self.github = github 39 | self.btc = btc 40 | self.location = location 41 | self.tagline = tagline 42 | self.bio = bio 43 | self.avatar = avatar 44 | self.avatarMini = avatarMini 45 | self.avatarNormal = avatarNormal 46 | self.avatarLarge = avatarLarge 47 | self.created = created 48 | self.lastModified = lastModified 49 | } 50 | 51 | enum CodingKeys: String, CodingKey { 52 | case id, username, url, website, twitter, psn, github, btc, location, tagline, bio, created, avatar 53 | case avatarMini = "avatar_mini" 54 | case avatarNormal = "avatar_normal" 55 | case avatarLarge = "avatar_large" 56 | case lastModified = "last_modified" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2exAPI 2 | [![Swift](https://img.shields.io/badge/swift-F54A2A?style=for-the-badge&logo=swift&logoColor=white)](https://github.com/isaced/V2exAPI) 3 | [![CocoaPods](https://img.shields.io/cocoapods/v/V2exAPI.svg?style=for-the-badge)](https://cocoapods.org/pods/V2exAPI) 4 | [![Carthage](https://img.shields.io/badge/-Carthage-5C5543?style=for-the-badge)](https://github.com/Carthage/Carthage) 5 | [![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=ios&logoColor=white)](https://github.com/isaced/V2exAPI) 6 | [![macOS](https://img.shields.io/badge/mac%20os-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)](https://github.com/isaced/V2exAPI) 7 | 8 | 中文|[English](/README_EN.md) 9 | 10 | 一个 [V2ex](https://v2ex.com/) API 的 Swift 封装,支持 iOS/macOS,支持 SPM 引入和 async/await 方式调用。 11 | 12 | ## 特性 13 | 14 | - [x] SPM(Swift Package Manager) 支持 15 | - [x] Swift async/await 异步 API 16 | - [x] V2ex API V1 支持度 100% 17 | - [x] V2ex API V2 支持度 100% 18 | - [x] Zero dependency 零三方依赖 19 | 20 | ## API 支持范围 21 | 22 | ### V1 23 | 24 | | 接口 | 路径 | 进度 | 25 | |------|-------------------------|-----| 26 | | 最热主题 | /api/topics/hot.json | ☑ | 27 | | 最新主题 | /api/topics/latest.json | ☑ | 28 | | 节点列表 | /api/nodes/list.json | ☑ | 29 | | 节点信息 | /api/nodes/show.json | ☑ | 30 | | 用户主页 | /api/members/show.json | ☑ | 31 | | 回复列表 | /api/replies/show.json | ☑ | 32 | 33 | ### V2 34 | 35 | | 接口 | 路径 | 进度 | 36 | |----------------------|--------------------------------|---------| 37 | | 获取最新的提醒 | notifications | ☑ | 38 | | 删除指定的提醒 | notifications/:notification_id | ☑ | 39 | | 获取自己的 Profile | member | ☑ | 40 | | 查看当前使用的令牌 | token | ☑ | 41 | | 获取指定节点 | nodes/:node_name | ☑ | 42 | | 获取指定节点下的主题 | nodes/:node_name/topics | ☑ | 43 | | 获取指定主题 | topics/:topic_id | ☑ | 44 | | 获取指定主题下的回复 | topics/:topic_id/replies | ☑ | 45 | 46 | ## 安装 47 | 48 | ### Swift Package Manager 49 | 50 | 通过 [Swift Package Manager](https://swift.org/package-manager/) 安装 V2exAPI 到你的项目,在 `Package.swift` 中添加: 51 | 52 | ```swift 53 | .package(name: "V2exAPI", url: "git@github.com:isaced/V2exAPI.git", .upToNextMinor(from: "1.0.0")), 54 | ``` 55 | 56 | 在 Xcode 中: 57 | - 菜单 File > Swift Packages > Add Package Dependency 58 | - 搜索 https://github.com/isaced/V2exAPI.git 59 | - 选择 "Up to Next Major" 版本 "1.0.0" 60 | 61 | 62 | ### CocoaPods 63 | 64 | ```ruby 65 | pod 'V2exAPI', '~> 1.0' 66 | ``` 67 | 68 | ## 使用 69 | 70 | ```swift 71 | import V2exAPI 72 | 73 | // 初始化 V2eXAPI 对象 74 | let v2ex = V2exAPI(accessToken: "XXXXX-XXXX-XXXX-XXXX-XXXXXXXXX") 75 | 76 | // 获取节点列表 77 | let nodes = try await v2ex.nodesList() 78 | 79 | // 获取最新主题 80 | let latest = try await v2ex.latestTopics() 81 | 82 | // 获取指定节点下的主题列表 83 | let topics = try await v2ex.topics(nodeName: "swift", page: 1) 84 | 85 | // 获取指定主题下的回复 86 | let replies = try await v2ex.replies(topicId: 870607, page: 1) 87 | ``` 88 | 89 | ## API 参考 90 | 91 | - [V2EX API 接口](https://www.v2ex.com/p/7v9TEc53) 92 | - [API 2.0 Beta](https://v2ex.com/help/api) 93 | 94 | API Rate Limit 95 | 96 | 默认情况下,每个 IP 每小时可以发起的 API 请求数被限制在 120 次。你可以在 API 返回结果的 HTTP 头部找到 Rate Limit 信息: 97 | 98 | ``` 99 | X-Rate-Limit-Limit: 120 100 | X-Rate-Limit-Reset: 1409479200 101 | X-Rate-Limit-Remaining: 116 102 | ``` 103 | 104 | 对于能够被 CDN 缓存的 API 请求,只有第一次请求时,才会消耗 Rate Limit 配额。 105 | 106 | > Personal Access Token 申请参考:https://v2ex.com/help/personal-access-token 107 | 108 | ## 使用示例 109 | 110 | - [V2exOS](https://github.com/isaced/V2exOS) - 一个用 SwiftUI 编写的 V2ex macOS 客户端 111 | 112 | ![screenshot](https://user-images.githubusercontent.com/2088605/192312063-def16466-052b-457a-9b4c-856b2afb3a42.png#gh-dark-mode-only) 113 | ![screenshot](https://user-images.githubusercontent.com/2088605/192312051-9ec1e43d-4aee-46fb-a61f-fd865e35fca4.png##gh-light-mode-only) 114 | 115 | ## License 116 | 117 | V2exAPI 在 MIT 许可下发布的,有关详细信息,请参阅 [LICENSE](/LICENSE) 118 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # V2exAPI 2 | [![Swift](https://img.shields.io/badge/swift-F54A2A?style=for-the-badge&logo=swift&logoColor=white)](https://github.com/isaced/V2exAPI) 3 | [![CocoaPods](https://img.shields.io/cocoapods/v/V2exAPI.svg?style=for-the-badge)](https://cocoapods.org/pods/V2exAPI) 4 | [![Carthage](https://img.shields.io/badge/-Carthage-5C5543?style=for-the-badge)](https://github.com/Carthage/Carthage) 5 | [![iOS](https://img.shields.io/badge/iOS-000000?style=for-the-badge&logo=ios&logoColor=white)](https://github.com/isaced/V2exAPI) 6 | [![macOS](https://img.shields.io/badge/mac%20os-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0)](https://github.com/isaced/V2exAPI) 7 | 8 | [中文](/README.md)|English 9 | 10 | A [V2ex](https://v2ex.com/) API wrapper for iOS/macOS, built with Swift. 11 | 12 | > V2EX is a community of start-ups, designers, developers and creative people. 13 | 14 | ## Features 15 | 16 | - [x] SPM(Swift Package Manager) Support 17 | - [x] Swift async/await Support 18 | - [x] V2ex API V1 100% 19 | - [x] V2ex API V2 100% 20 | - [x] Zero dependency 21 | 22 | ## API Support 23 | 24 | ### V1 25 | 26 | | 接口 | 路径 | 进度 | 27 | |------|-------------------------|-----| 28 | | Hottest topic | /api/topics/hot.json | ☑ | 29 | | Latest Topics | /api/topics/latest.json | ☑ | 30 | | Node list | /api/nodes/list.json | ☑ | 31 | | Node Info | /api/nodes/show.json | ☑ | 32 | | Profile info | /api/members/show.json | ☑ | 33 | | Replies | /api/replies/show.json | ☑ | 34 | 35 | ### V2 36 | 37 | | 接口 | 路径 | 进度 | 38 | |----------------------|--------------------------------|---------| 39 | | Get the latest notifications | notifications | ☑ | 40 | | Delete the specified notification | notifications/:notification_id | ☑ | 41 | | Get your own Profile | member | ☑ | 42 | | Get the currently used token | token | ☑ | 43 | | get the specified node | nodes/:node_name | ☑ | 44 | | Get the topics under the specified node | nodes/:node_name/topics | ☑ | 45 | | Get specific topic details | topics/:topic_id | ☑ | 46 | | Get replies under the specified topic | topics/:topic_id/replies | ☑ | 47 | 48 | ## Installation 49 | 50 | ### Swift Package Manager 51 | 52 | via [Swift Package Manager](https://swift.org/package-manager/) Install to your project,Add in `Package.swift`: 53 | 54 | ```swift 55 | .package(name: "V2exAPI", url: "git@github.com:isaced/V2exAPI.git", .upToNextMinor(from: "1.0.0")), 56 | ``` 57 | 58 | In Xcode: 59 | - File > Swift Packages > Add Package Dependency 60 | - Add https://github.com/isaced/V2exAPI.git 61 | - Select "Up to Next Major" with "1.0.0" 62 | 63 | ### CocoaPods 64 | 65 | ```ruby 66 | pod 'V2exAPI', '~> 1.0' 67 | ``` 68 | 69 | ## Useage 70 | 71 | ```swift 72 | import V2exAPI 73 | 74 | // Init V2eXAPI object 75 | let v2ex = V2exAPI(accessToken: "XXXXX-XXXX-XXXX-XXXX-XXXXXXXXX") 76 | 77 | // Get node list 78 | let nodes = try await v2ex.nodesList() 79 | 80 | // Get latest topics 81 | let latest = try await v2ex.latestTopics() 82 | 83 | // Get the topics under the specified node 84 | let topics = try await v2ex.topics(nodeName: "swift", page: 1) 85 | 86 | // Get replies under the specified topic 87 | let replies = try await v2ex.replies(topicId: 870607, page: 1) 88 | ``` 89 | 90 | ## API Reference 91 | 92 | - [V2EX API interface](https://www.v2ex.com/p/7v9TEc53) 93 | - [API 2.0 Beta](https://v2ex.com/help/api) 94 | 95 | API Rate Limit 96 | 97 | By default, each IP is limited to 120 API requests per hour. You can find the Rate Limit information in the HTTP header of the API return result: 98 | 99 | ``` 100 | X-Rate-Limit-Limit: 120 101 | X-Rate-Limit-Reset: 1409479200 102 | X-Rate-Limit-Remaining: 116 103 | ``` 104 | 105 | For API requests that can be cached by the CDN, the Rate Limit quota will only be consumed on the first request. 106 | 107 | > Personal Access Token:https://v2ex.com/help/personal-access-token 108 | 109 | ## Examples 110 | 111 | - [V2exOS](https://github.com/isaced/V2exOS) - A SwiftUI V2ex client for macOS 112 | 113 | ![screenshot](https://user-images.githubusercontent.com/2088605/182183782-79aa8524-dea4-40d3-87a3-6b542678f568.png#gh-dark-mode-only) 114 | ![screenshot](https://user-images.githubusercontent.com/2088605/182184352-52019bd0-da89-4703-9d83-2b85aa10617e.png##gh-light-mode-only) 115 | 116 | 117 | ## License 118 | 119 | V2exAPI Kingfisher is released under the MIT license. See [LICENSE](/LICENSE) for details. 120 | -------------------------------------------------------------------------------- /Sources/V2exAPI/V2exAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// V2ex API 4 | public struct V2exAPI { 5 | 6 | /** 7 | 个人访问令牌 8 | 9 | 生成参考: https://v2ex.com/help/personal-access-token 10 | */ 11 | public var accessToken: String? 12 | public var session = URLSession.shared 13 | 14 | private let endpointV1 = "https://v2ex.com/api/" 15 | private let endpointV2 = "https://www.v2ex.com/api/v2/" 16 | 17 | public init(accessToken: String? = nil) { 18 | self.accessToken = accessToken 19 | } 20 | 21 | /** 22 | HTTP 请求 23 | */ 24 | private func request(httpMethod: String = "GET", url: String, args: [String: Any]? = nil, decodeClass: T.Type) async throws -> ( 25 | T?, URLResponse? 26 | ) where T : Decodable { 27 | let urlComponents = NSURLComponents(string: url)! 28 | 29 | if httpMethod != "POST" && args != nil { 30 | urlComponents.queryItems = 31 | args?.map({ (k, v) in 32 | return NSURLQueryItem(name: k, value: "\(v)") 33 | }) as [URLQueryItem]? 34 | } 35 | 36 | guard let requestUrl = urlComponents.url else { 37 | return (nil, nil) 38 | } 39 | 40 | var request = URLRequest(url: requestUrl) 41 | request.httpMethod = httpMethod 42 | request.addValue("application/json", forHTTPHeaderField: "Content-Type") 43 | 44 | if let accessToken = accessToken { 45 | request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization") 46 | } 47 | 48 | if httpMethod == "POST" && args != nil{ 49 | request.httpBody = try? JSONSerialization.data(withJSONObject: args as Any) 50 | } 51 | 52 | let (data, response) = try await session.data(for: request) 53 | 54 | let decoder = JSONDecoder() 55 | 56 | let obj = try decoder.decode(decodeClass.self, from: data) 57 | 58 | return (obj, response) 59 | } 60 | 61 | 62 | // =========== Others =========== 63 | 64 | /** 65 | 获取节点列表 66 | */ 67 | public func nodesList(fields: [String]? = nil, sortBy: String = "topics", reverse: String = "1") async throws -> [V2Node]? { 68 | var fieldsList = ["id","name","title","topics","aliases"] 69 | if let fields { 70 | fieldsList = fields 71 | } 72 | let (data, _) = try await request( 73 | url: endpointV1 + "nodes/list.json", 74 | args: [ 75 | "fields": fieldsList.joined(separator: ","), 76 | "sort_by": sortBy, 77 | "reverse": reverse, 78 | ], 79 | decodeClass: [V2Node].self 80 | ) 81 | return data; 82 | } 83 | 84 | // =========== V1 =========== 85 | 86 | /** 87 | 最热主题 88 | 89 | 相当于首页右侧的 10 大每天的内容。 90 | */ 91 | public func hotTopics() async throws -> [V2Topic]? { 92 | let (data, _) = try await request( 93 | url: endpointV1 + "topics/hot.json", 94 | decodeClass: [V2Topic].self 95 | ) 96 | return data 97 | } 98 | 99 | /** 100 | 最新主题 101 | 102 | 相当于首页的“全部”这个 tab 下的最新内容。 103 | */ 104 | public func latestTopics() async throws -> [V2Topic]? { 105 | let (data, _) = try await request( 106 | url: endpointV1 + "topics/latest.json", 107 | decodeClass: [V2Topic].self 108 | ) 109 | return data 110 | } 111 | 112 | /** 113 | 节点信息 114 | 115 | 获得指定节点的名字,简介,URL 及头像图片的地址。 116 | 117 | - parameter name: 节点名(V2EX 的节点名全是半角英文或者数字) 118 | */ 119 | public func nodesShow(name: String) async throws -> V2Node? { 120 | let (data, _) = try await request( 121 | url: endpointV1 + "nodes/show.json", 122 | args: [ 123 | "name": name 124 | ], 125 | decodeClass: V2Node.self 126 | ) 127 | return data; 128 | } 129 | 130 | /** 131 | 用户主页 132 | 133 | 获得指定用户的自我介绍,及其登记的社交网站信息。 134 | 135 | - parameter username: 用户名 136 | - parameter id: 用户在 V2EX 的数字 ID 137 | */ 138 | public func memberShow(username: String? = nil, id: Int? = nil) async throws -> V2Member? { 139 | var args:[String:String] = [:] 140 | if let username = username { 141 | args["username"] = username; 142 | } 143 | if let id = id { 144 | args["id"] = String(id); 145 | } 146 | 147 | if args.isEmpty { 148 | return nil 149 | } 150 | 151 | let (data, _) = try await request( 152 | url: endpointV1 + "members/show.json", 153 | args: args, 154 | decodeClass: V2Member.self 155 | ) 156 | return data; 157 | } 158 | 159 | /** 160 | 获取指定主题下的回复列表 161 | 162 | - parameter topicId: 主题ID 163 | */ 164 | public func repliesAll(topicId: Int) async throws -> [V2Comment]? { 165 | let path = "replies/show.json" 166 | let (data, _) = try await request( 167 | url: endpointV1 + path, 168 | args: [ 169 | "topic_id": topicId 170 | ], 171 | decodeClass: [V2Comment].self 172 | ) 173 | return data 174 | } 175 | 176 | /** 177 | 获取节点下的主题列表 178 | 179 | - parameter topicId: 主题ID 180 | */ 181 | public func topics(nodeName: String) async throws -> [V2Topic]? { 182 | let path = "topics/show.json" 183 | let (data, _) = try await request( 184 | url: endpointV1 + path, 185 | args: [ 186 | "node_name": nodeName 187 | ], 188 | decodeClass: [V2Topic].self 189 | ) 190 | return data 191 | } 192 | 193 | // =========== V2 =========== 194 | 195 | /** 196 | 获取指定节点下的主题 197 | 198 | - parameter nodeName: 节点名,如 "swift" 199 | - parameter page: 分页页码,默认为 1 200 | */ 201 | public func topics(nodeName: String, page: Int = 1) async throws -> V2Response<[V2Topic]?>? { 202 | let path = "nodes/\(nodeName)/topics" 203 | let (data, _) = try await request( 204 | url: endpointV2 + path, 205 | args: [ 206 | "p": String(page) 207 | ], 208 | decodeClass: V2Response<[V2Topic]?>.self 209 | ) 210 | return data 211 | } 212 | 213 | /** 214 | 获取指定主题下的回复 215 | 216 | - parameter topicId: 主题ID 217 | - parameter page: 分页页码,默认为 1 218 | */ 219 | public func replies(topicId: Int, page: Int = 1) async throws -> V2Response<[V2Comment]?>? { 220 | let path = "topics/\(topicId)/replies" 221 | let (data, _) = try await request( 222 | url: endpointV2 + path, 223 | args: [ 224 | "p": String(page) 225 | ], 226 | decodeClass: V2Response<[V2Comment]?>.self 227 | ) 228 | return data 229 | } 230 | 231 | /** 232 | 获取指定主题 233 | 234 | - parameter topicId: 主题ID 235 | */ 236 | public func topic(topicId: Int) async throws -> V2Response? { 237 | let path = "topics/\(topicId)" 238 | let (data, _) = try await request( 239 | url: endpointV2 + path, 240 | decodeClass: V2Response.self 241 | ) 242 | return data 243 | } 244 | 245 | /** 246 | 获取指定节点 247 | 248 | - parameter nodeName: 节点名 249 | */ 250 | public func getNode(nodeName: String) async throws -> V2Response? { 251 | let path = "nodes/\(nodeName)" 252 | let (data, _) = try await request( 253 | url: endpointV2 + path, 254 | decodeClass: V2Response.self 255 | ) 256 | return data 257 | } 258 | 259 | /** 260 | 获取最新的提醒 261 | 262 | - parameter page: 分页页码,默认为 1 263 | */ 264 | public func notifications(page: Int = 1) async throws -> V2Response<[V2Notification]?>? { 265 | let path = "notifications" 266 | let (data, _) = try await request( 267 | url: endpointV2 + path, 268 | args: [ 269 | "p": String(page) 270 | ], 271 | decodeClass: V2Response<[V2Notification]?>.self 272 | ) 273 | return data 274 | } 275 | 276 | /** 277 | 获取最新的提醒 278 | 279 | - parameter notification_id: 提醒ID 280 | */ 281 | public func deleteNotification(notificationId: Int) async throws -> V2Response? { 282 | let path = "notifications/\(notificationId)" 283 | let (data, _) = try await request( 284 | httpMethod: "DELETE", 285 | url: endpointV2 + path, 286 | decodeClass: V2Response.self 287 | ) 288 | return data 289 | } 290 | 291 | 292 | /** 293 | 获取自己的 Profile 294 | */ 295 | public func member() async throws -> V2Response? { 296 | let path = "member" 297 | let (data, _) = try await request( 298 | url: endpointV2 + path, 299 | decodeClass: V2Response.self 300 | ) 301 | return data 302 | } 303 | 304 | 305 | /** 306 | 查看当前使用的令牌 307 | */ 308 | public func token() async throws -> V2Response? { 309 | let path = "token" 310 | let (data, _) = try await request( 311 | url: endpointV2 + path, 312 | decodeClass: V2Response.self 313 | ) 314 | return data 315 | } 316 | 317 | /** 318 | 创建新的令牌 319 | 320 | 你可以在系统中最多创建 10 个 Personal Access Token。 321 | 322 | - parameter scope: 可选 everything 或者 regular,如果是 regular 类型的 Token 将不能用于进一步创建新的 token 323 | - parameter expiration: 可支持的值:2592000,5184000,7776000 或者 15552000,即 30 天,60 天,90 天或者 180 天的秒数 324 | */ 325 | public func createToken(expiration: Int, scope: String? = nil) async throws -> V2Response? { 326 | let path = "token" 327 | var args:[String: Any] = ["expiration": expiration] 328 | if let scope = scope { 329 | args["scope"] = scope 330 | } 331 | 332 | let (data, _) = try await request( 333 | httpMethod: "POST", 334 | url: endpointV2 + path, 335 | args: args, 336 | decodeClass: V2Response.self 337 | ) 338 | return data 339 | } 340 | } 341 | --------------------------------------------------------------------------------