diff --git a/README.md b/README.md index d31acfd..35469b0 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,108 @@ # Bilibili API JVM 调用库 该项目提供 Bilibili API 的 JVM 调用, 协议来自 Bilibili Android APP 的逆向工程以及截包分析. +# 技术说明 +`BilibiliClient` 类表示一个模拟的客户端, 实例化此类即表示打开了 Bilibili APP. + +所有调用从这个类开始, 包括登陆以及访问其他各种 API. + +使用协程来实现异步, 由于 [kotlin coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) 为编译器实现, 因此并非所有 JVM 语言都能正确调用 `suspend` 方法. + +本项目尽可能的兼容其他 JVM 语言和 Android, 不要问, 问就没测试过. + +`BilibiliClient` 实例化时会记录一些信息, 例如初始化的事件, 用于更逼真的模拟真实客户端发送的请求. 因此请不要每次都实例化一个新的 `BilibiliClient` 实例, 而应该保存其引用. + +一个客户端下各种不同类型的 API (代理类)都是惰性初始化的, 并且只初始化一次, 因此不需要保存 API 的引用, 例如以下代码是被推荐的: + +```kotlin +runBlocking { + val bilibiliClient = BilibiliClient().apply { + login(username, password) + } + val myInfo = bilibiliClient.appAPI.myInfo().await() + val reply = bilibiliClient.mainAPI.reply(oid = 44154463).await() +} +``` + +如果一个请求的返回内容中的 `code`(code 是 BODY 的内容, 并非 HttpStatus) 不为 0, 将抛出异常 `BilibiliApiException`, 通过以下代码来获取服务器原始返回的 `code`: + +```kotlin +val code = bilibiliApiException.commonResponse.code +``` + +一个错误返回的原始 `JSON` 如下所示: + +```json +{ + "code": -629, + "message": "用户名与密码不匹配", + "ts": 1550730464 +} +``` + +每种不同的 API 在错误时返回的 `code` 丰富多彩(确信), 可能是正数也可能是负数, 可能上万也可能是个位数, 不要问, 问就是你菜. + # 登录和登出 -https://passport.bilibili.com +(Bilibili oauth2 v3) 登陆和登出均为异步方法, 需要在协程上下文中执行. - runBlocking { - BilibiliClient().run { - login(username, password) - logout() - } +```kotlin +runBlocking { + BilibiliClient().run { + login(username, password) + logout() } +} +``` `login` 方法返回一个 `LoginResponse` 实例, 下次可以直接赋值到没有登陆的 `BilibiliClient` 实例中来恢复登陆状态. - BilibiliClient().apply { - this.loginResponse = loginResponse - } +```kotlin +BilibiliClient().apply { + this.loginResponse = loginResponse +} +``` -`LoginResponse` 继承 `Serializable`, 可被序列化. +`LoginResponse` 继承 `Serializable`, 可被序列化(JVM 序列化). -如果登录操作失败, 将抛出 `BilibiliApiException`(只要服务器返回的 code 不为 0 都将抛出异常), 通过以下代码获取服务器返回的 `code` +可能的错误返回有两种: - val code = bilibiliApiException.commonResponse.code + -629 用户名与密码不匹配 + -105 验证码错误 -在登陆操作中, 如果服务器返回 `-105` 表明本次登陆需要验证码(通常是由于多次错误的登陆尝试导致的), 原始返回如下所示 +如果仅使用用户名与密码进行登陆并且得到了 `-105` 的结果, 那么说明需要验证码(通常是由于多次错误的登陆尝试导致的). + +原始返回如下所示 {"ts":1550569982,"code":-105,"data":{"url":"https://passport.bilibili.com/register/verification.html?success=1>=b6e5b7fad7ecd37f465838689732e788&challenge=9a67afa4d42ede71a93aeaaa54a4b6fe&ct=1&hash=105af2e7cc6ea829c4a95205f2371dc5"},"message":"验证码错误!"} -自行访问 `commonResponse.data.obj.url.string` 将打开一个极验弹窗, 通过验证码后再次调用登陆接口: +自行访问 `commonResponse.data.obj.url.string` 打开一个极验弹窗, 完成滑动验证码后再次调用登陆接口: - login(username, password, challenge, secCode, validate) +```kotlin +login(username, password, challenge, secCode, validate) +``` -`challenge` 为本次极验的唯一标识 +`challenge` 为本次极验的唯一标识(在一开始给出的 url 中) `validate` 为极验返回值 `secCode` 为 `"$validate|jordan"` +(注意, 极验会根据滑动的轨迹来识别人机, 所以要为最终用户打开一个 WebView 来进行真人操作而不能自动完成. 极验最终返回的是一个 jsonp, 里面包含以上三个参数, 详见极验接入文档). + 注意, `BilibiliClient` 不能严格保证线程安全, 如果在登出的同时进行登录操作可能引发错误. 登陆后, 可以访问全部 API. -# message -https://message.bilibili.com +# 访问 API +通常的 API 访问是这样的 - BilibiliClient().messageAPI +```kotlin +val myInfo = bilibiliClient.appAPI.myInfo().await() +``` -消息通知有关的接口. - -# app -https://app.bilibili.com - - BilibiliClient().appAPI - -总站 API. 获取个人信息的完整示例如下: - - runBlocking { - val bilibiliClient = BilibiliClient().apply { - login(username, password) - } - val myInfo = bilibiliClient.appAPI.myInfo().await() - println(myInfo) - } - -# av -https://api.vc.bilibili.com - - BilibiliClient().vcAPI - -小视频. - -# member -https://member.bilibili.com - - BilibiliClient().memberAPI - -创作中心. +不要问文档, 用自动补全(心)来感受. # License GPL V3 diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt b/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt index c6f7ff8..ed8bd4d 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt @@ -15,10 +15,15 @@ object BaseUrl { const val message = "https://message.bilibili.com" /** - * 提供通用功能, 例如获取用户信息 + * 主站 */ const val app = "https://app.bilibili.com" + /** + * 这也是主站 + */ + const val main = "https://api.bilibili.com" + /** * 小视频 */ diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt index 2336576..8a0136d 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt @@ -1,6 +1,7 @@ package com.hiczp.bilibili.api import com.hiczp.bilibili.api.app.AppAPI +import com.hiczp.bilibili.api.main.MainAPI import com.hiczp.bilibili.api.member.MemberAPI import com.hiczp.bilibili.api.message.MessageAPI import com.hiczp.bilibili.api.passport.PassportAPI @@ -31,13 +32,11 @@ import javax.crypto.Cipher * 不能严格保证线程安全. * * @param billingClientProperties 客户端的固有属性, 是一种常量 - * @param autoRefreshToken 当 Token 过期时是否自动重新登录 * @param logLevel 日志打印等级 */ class BilibiliClient( @Suppress("MemberVisibilityCanBePrivate") val billingClientProperties: BilibiliClientProperties = BilibiliClientProperties(), - private val autoRefreshToken: Boolean = true, //TODO 自动 refreshToken private val logLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.NONE ) { /** @@ -134,6 +133,24 @@ class BilibiliClient( ) } + /** + * 这也是总站 API + */ + @Suppress("SpellCheckingInspection") + val mainAPI by lazy { + createAPI(BaseUrl.main, + CommonHeaderInterceptor( + //如果未登陆则没有 Display-ID + "Display-ID" to { userId?.let { "$it-$initTime" } }, + "Buvid" to { billingClientProperties.buildVersionId }, + "User-Agent" to { "Mozilla/5.0 BiliDroid/5.37.0 (bbcallen@gmail.com)" }, + "Device-ID" to { billingClientProperties.hardwareId } + ), + defaultCommonQueryParamInterceptor, + defaultQuerySignInterceptor + ) + } + /** * 小视频相关接口 */ diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt index 6b210c7..2b49808 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt @@ -6,7 +6,7 @@ package com.hiczp.bilibili.api */ class BilibiliClientProperties { /** - * Android 平台的 appKey + * Android 平台的 appKey(该默认值为普通版客户端, 非概念版) */ var appKey = "1d8b6e7d45233436" @@ -16,6 +16,18 @@ class BilibiliClientProperties { @Suppress("SpellCheckingInspection") var appSecret = "560c52ccd288fed045859ed18bffd973" + /** + * 获取视频播放地址使用的 appKey, 与访问其他 RestFulAPI 所用的 appKey 是不一样的 + */ + @Suppress("SpellCheckingInspection") + var videoAppKey = "iVGUTjsxvpLeuDCf" + + /** + * 获取视频播放地址所用的 appSecret + */ + @Suppress("SpellCheckingInspection") + var videoAppSecret = "aHRmhWMLkdeMuILqORnYZocwMBpMEOdt" + /** * 客户端平台 */ diff --git a/src/main/kotlin/com/hiczp/bilibili/api/app/AppAPI.kt b/src/main/kotlin/com/hiczp/bilibili/api/app/AppAPI.kt index 01d6f07..d5c2aa7 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/app/AppAPI.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/app/AppAPI.kt @@ -5,13 +5,19 @@ import kotlinx.coroutines.Deferred import retrofit2.http.GET import retrofit2.http.Query import java.time.Instant -import java.util.* /** * 总站 API */ @Suppress("DeferredIsResult") interface AppAPI { + /** + * 打开 APP 时将访问此接口来获得 UI 排布顺序 + * 包括下方 tab(首页, 频道, 动态, 会员购), 首页的上方 tab(直播, 推荐, 热门, 追番) 以及右上角的 游戏中心, 离线下载, 消息 + */ + @GET("/x/resource/show/tab") + fun tab(): Deferred + /** * 登陆完成后将请求一次此接口以获得个人资料 */ @@ -73,8 +79,9 @@ interface AppAPI { ): Deferred /** - * 视频页面 + * 视频页面(普通视频, 非番剧) * 包含视频基本信息, 推荐和广告 + * 从这个接口得到视频的 cid * * @param aid 视频的唯一标识 */ @@ -93,29 +100,29 @@ interface AppAPI { @Query("trackid") trackId: String? = null //all_10.shylf-ai-recsys-120.1550674524909.237 ): Deferred - //TODO 这里的 appkey 变为 iVGUTjsxvpLeuDCf - /** - * 获得视频的播放地址 - * - * @param expire 默认为下个月的这一天的时间戳 - * @param mid 当前用户 ID - * @param cid 在 view() 接口的返回值里 - * @param aid 视频的唯一标识 - */ - @Suppress("SpellCheckingInspection") - @GET("/x/playurl") - fun playUrl( - @Query("device") device: String = "android", - @Query("expire") expire: Long = Calendar.getInstance().apply { add(Calendar.MONTH, 1) }.toInstant().epochSecond, - @Query("force_host") forceHost: Int = 0, - @Query("mid") mid: Long? = null, - @Query("fnval") fnVal: Int = 16, - @Query("qn") qn: Int = 32, - @Query("npcybs") npcybs: Int = 0, - @Query("cid") cid: Long? = null, - @Query("otype") otype: String = "json", - @Query("fnver") fnVer: Int = 0, - @Query("buvid") buildVersionId: String? = null, - @Query("aid") aid: Long - ): Deferred +// //TODO 这里的 appkey 变为 iVGUTjsxvpLeuDCf +// /** +// * 获得视频的播放地址 +// * +// * @param expire 默认为下个月的这一天的时间戳 +// * @param mid 当前用户 ID +// * @param cid 在 view() 接口的返回值里 +// * @param aid 视频的唯一标识 +// */ +// @Suppress("SpellCheckingInspection") +// @GET("/x/playurl") +// fun playUrl( +// @Query("device") device: String = "android", +// @Query("expire") expire: Long = Calendar.getInstance().apply { add(Calendar.MONTH, 1) }.toInstant().epochSecond, +// @Query("force_host") forceHost: Int = 0, +// @Query("mid") mid: Long? = null, +// @Query("fnval") fnVal: Int = 16, +// @Query("qn") qn: Int = 32, +// @Query("npcybs") npcybs: Int = 0, +// @Query("cid") cid: Long? = null, +// @Query("otype") otype: String = "json", +// @Query("fnver") fnVer: Int = 0, +// @Query("buvid") buildVersionId: String? = null, +// @Query("aid") aid: Long +// ): Deferred } diff --git a/src/main/kotlin/com/hiczp/bilibili/api/app/model/HomePage.kt b/src/main/kotlin/com/hiczp/bilibili/api/app/model/HomePage.kt index 2c66e2d..d060718 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/app/model/HomePage.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/app/model/HomePage.kt @@ -1,5 +1,6 @@ package com.hiczp.bilibili.api.app.model +import com.google.gson.JsonElement import com.google.gson.annotations.SerializedName data class HomePage( @@ -172,9 +173,9 @@ data class HomePage( @SerializedName("card") var card: Card, @SerializedName("click_urls") - var clickUrls: List, + var clickUrls: List, @SerializedName("download_whitelist") - var downloadWhitelist: List, + var downloadWhitelist: List, @SerializedName("open_whitelist") var openWhitelist: List, @SerializedName("preload_landingpage") @@ -184,7 +185,7 @@ data class HomePage( @SerializedName("sales_type") var salesType: Int, // 12 @SerializedName("show_urls") - var showUrls: List, + var showUrls: List, @SerializedName("special_industry") var specialIndustry: Boolean, // false @SerializedName("special_industry_tips") @@ -288,7 +289,7 @@ data class HomePage( @SerializedName("sales_type") var salesType: Int, // 31 @SerializedName("show_urls") - var showUrls: List, + var showUrls: List, @SerializedName("special_industry") var specialIndustry: Boolean, // false @SerializedName("special_industry_tips") @@ -327,7 +328,7 @@ data class HomePage( @SerializedName("jump_url") var jumpUrl: String, // bilibili://game_center/detail?id=80&sourceFrom=782&sourceType=adPut @SerializedName("report_urls") - var reportUrls: List, + var reportUrls: List, @SerializedName("text") var text: String, @SerializedName("type") diff --git a/src/main/kotlin/com/hiczp/bilibili/api/app/model/MyInfo.kt b/src/main/kotlin/com/hiczp/bilibili/api/app/model/MyInfo.kt index 88e3ec9..949b1ca 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/app/model/MyInfo.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/app/model/MyInfo.kt @@ -55,7 +55,7 @@ data class MyInfo( data class Vip( @SerializedName("due_date") - var dueDate: Int, // 0 + var dueDate: Long, // 0 @SerializedName("status") var status: Int, // 0 @SerializedName("type") diff --git a/src/main/kotlin/com/hiczp/bilibili/api/app/model/PlayUrl.kt b/src/main/kotlin/com/hiczp/bilibili/api/app/model/PlayUrl.kt index 75364b5..605d88c 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/app/model/PlayUrl.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/app/model/PlayUrl.kt @@ -1,80 +1,78 @@ package com.hiczp.bilibili.api.app.model -import com.google.gson.annotations.SerializedName - -data class PlayUrl( - @SerializedName("code") - var code: Int, // 0 - @SerializedName("data") - var `data`: Data, - @SerializedName("message") - var message: String, // 0 - @SerializedName("ttl") - var ttl: Int // 1 -) { - data class Data( - @SerializedName("accept_description") - var acceptDescription: List, - @SerializedName("accept_format") - var acceptFormat: String, // flv720,flv480,flv360 - @SerializedName("accept_quality") - var acceptQuality: List, - @SerializedName("dash") - var dash: Dash, - @SerializedName("fnval") - var fnval: Int, // 16 - @SerializedName("fnver") - var fnver: Int, // 0 - @SerializedName("format") - var format: String, // flv480 - @SerializedName("from") - var from: String, // local - @SerializedName("quality") - var quality: Int, // 32 - @SerializedName("result") - var result: String, // suee - @SerializedName("seek_param") - var seekParam: String, // start - @SerializedName("seek_type") - var seekType: String, // offset - @SerializedName("timelength") - var timelength: Int, // 443737 - @SerializedName("video_codecid") - var videoCodecid: Int, // 7 - @SerializedName("video_project") - var videoProject: Boolean // true - ) { - data class Dash( - @SerializedName("audio") - var audio: List