From 6acaad0445e48cd541d1598b2308088aebe58f1b Mon Sep 17 00:00:00 2001 From: czp3009 Date: Mon, 18 Feb 2019 19:08:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=87=A0=E4=B8=AA=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 30 ++++++++- .../kotlin/com/hiczp/bilibili/api/BaseUrl.kt | 16 +++++ .../com/hiczp/bilibili/api/BilibiliClient.kt | 67 ++++++++++++++++++- .../bilibili/api/BilibiliClientProperties.kt | 33 +++++---- .../bilibili/api/passport/PassportAPI.kt | 18 +++++ .../api/passport/model/GetKeyResponse.kt | 21 ++++++ .../api/passport/model/LoginResponse.kt | 54 +++++++++++++++ .../hiczp/bilibili/api/retrofit/HttpMethod.kt | 10 +++ .../api/retrofit/RetrofitExtension.kt | 34 ++++++++++ .../interceptor/CommonHeaderInterceptor.kt | 20 ++++++ .../interceptor/CommonParamInterceptor.kt | 59 ++++++++++++++++ .../api/retrofit/interceptor/ParamType.kt | 6 ++ .../interceptor/SortAndSignInterceptor.kt | 66 ++++++++++++++++++ 13 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/passport/PassportAPI.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/passport/model/GetKeyResponse.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/passport/model/LoginResponse.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpMethod.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/retrofit/RetrofitExtension.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonHeaderInterceptor.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonParamInterceptor.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/ParamType.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt diff --git a/build.gradle b/build.gradle index c9fc4d0..6c34be2 100644 --- a/build.gradle +++ b/build.gradle @@ -25,11 +25,37 @@ repositories { //kotlin dependencies { - implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' + // https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib-jdk8 + compile group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '1.3.21' + // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core + compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '1.1.1' } compileKotlin { - kotlinOptions.jvmTarget = jvm_target + kotlinOptions { + jvmTarget = jvm_target + freeCompilerArgs = ["-Xjvm-default=enable"] + } } compileTestKotlin { kotlinOptions.jvmTarget = jvm_target } + +//logging +dependencies { + // https://mvnrepository.com/artifact/io.github.microutils/kotlin-logging + compile group: 'io.github.microutils', name: 'kotlin-logging', version: '1.6.25' + // https://mvnrepository.com/artifact/org.slf4j/slf4j-log4j12 + testCompile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25' +} + +//http +dependencies { + // https://mvnrepository.com/artifact/com.squareup.retrofit2/retrofit + compile group: 'com.squareup.retrofit2', name: 'retrofit', version: '2.5.0' + // https://mvnrepository.com/artifact/com.squareup.retrofit2/converter-gson + compile group: 'com.squareup.retrofit2', name: 'converter-gson', version: '2.5.0' + // https://mvnrepository.com/artifact/com.jakewharton.retrofit/retrofit2-kotlin-coroutines-adapter + compile group: 'com.jakewharton.retrofit', name: 'retrofit2-kotlin-coroutines-adapter', version: '0.9.2' + // https://mvnrepository.com/artifact/com.squareup.okhttp3/logging-interceptor + compile group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '3.13.1' +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt b/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt new file mode 100644 index 0000000..b1e2ca2 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/BaseUrl.kt @@ -0,0 +1,16 @@ +package com.hiczp.bilibili.api + +/** + * 各个站点的域名 + */ +object BaseUrl { + /** + * passport 站, 用于登录 + */ + val passport = "https://passport.bilibili.com" + + /** + * 直播站 + */ + val live = "https://api.live.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 1eccdac..852e5e2 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt @@ -1,22 +1,85 @@ package com.hiczp.bilibili.api +import com.hiczp.bilibili.api.passport.PassportAPI +import com.hiczp.bilibili.api.retrofit.interceptor.CommonHeaderInterceptor +import com.hiczp.bilibili.api.retrofit.interceptor.CommonParamInterceptor +import com.hiczp.bilibili.api.retrofit.interceptor.ParamType +import com.hiczp.bilibili.api.retrofit.interceptor.SortAndSignInterceptor +import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory +import mu.KotlinLogging +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory import java.time.Instant +private val logger = KotlinLogging.logger {} + /** * 此类表示一个模拟的 Bilibili 客户端(Android), 所有调用由此开始. * 多个 BilibiliClient 实例之间不共享登陆状态. * 不能严格保证线程安全. * * @param billingClientProperties 客户端的固有属性, 是一种常量 + * @param autoRefreshToken 当 Token 过期时是否自动重新登录 + * @param debug 是否打印请求日志 */ class BilibiliClient( @Suppress("MemberVisibilityCanBePrivate") - var billingClientProperties: BilibiliClientProperties = BilibiliClientProperties() + val billingClientProperties: BilibiliClientProperties = BilibiliClientProperties(), + private val autoRefreshToken: Boolean = true, + private val debug: Boolean = false ) { /** * 客户端被打开的时间(BilibiliClient 被实例化的时间) */ - val clientInitTime = Instant.now().epochSecond + private val initTime = Instant.now().epochSecond + + /** + * 是否已登录 + */ + @Suppress("MemberVisibilityCanBePrivate") + var isLogin = false + + @Suppress("SpellCheckingInspection") + val passportAPI: PassportAPI by lazy { + val okHttpClient = OkHttpClient.Builder().apply { + addInterceptor(CommonHeaderInterceptor( + "Display-ID" to { + if (isLogin) { + "${billingClientProperties.buildVersionId}-$initTime" + } else { + billingClientProperties.buildVersionId + } + }, + "Buvid" to { billingClientProperties.buildVersionId }, + "User-Agent" to { "Mozilla/5.0 BiliDroid/5.37.0 (bbcallen@gmail.com)" }, + "Device-ID" to { billingClientProperties.hardwareId } + )) + addInterceptor(CommonParamInterceptor(ParamType.FORM_URL_ENCODED, + "appkey" to { billingClientProperties.appKey }, + "build" to { billingClientProperties.build }, + "channel" to { billingClientProperties.channel }, + "mobi_app" to { billingClientProperties.platform }, + "platform" to { billingClientProperties.platform }, + "ts" to { Instant.now().epochSecond.toString() } + )) + addInterceptor(SortAndSignInterceptor(ParamType.FORM_URL_ENCODED, billingClientProperties.appSecret)) + + //debug + if (debug) { + addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + }.build() + + Retrofit.Builder() + .baseUrl(BaseUrl.passport) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(CoroutineCallAdapterFactory()) + .client(okHttpClient) + .build() + .create(PassportAPI::class.java) + } /** * 登陆 diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt index 1cee0b3..6b210c7 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt @@ -2,6 +2,7 @@ package com.hiczp.bilibili.api /** * 客户端固有属性. 包括版本号, 密钥以及硬件编码. + * 默认值对应 5.37.0(release-b220051) 版本. */ class BilibiliClientProperties { /** @@ -16,19 +17,34 @@ class BilibiliClientProperties { var appSecret = "560c52ccd288fed045859ed18bffd973" /** - * 硬件 ID, 尚不明确生成算法. 在每台手机上固定 + * 客户端平台 + */ + var platform = "android" + + /** + * 客户端类型 + * 此属性在旧版客户端不存在 + */ + var channel = "html5_app_bili" + + /** + * 硬件 ID, 尚不明确生成算法 */ @Suppress("SpellCheckingInspection") - var hardwareId = "JxdyESFAJkcjEicQbBBsCTlbal5uX2Y" + var hardwareId = "aBRoDWAVeRhsA3FDewMzS3lLMwM" /** * 屏幕尺寸, 大屏手机(已经没有小屏手机了)统一为 xxhdpi */ var scale = "xxhdpi" + /** + * 版本号 + */ + var version = "5.37.0.5370000" + /** * 构建版本号 - * 默认值对应 5.37.0(release-b220051) 版本 */ var build = "5370000" @@ -36,15 +52,4 @@ class BilibiliClientProperties { * 构建版本 ID, 可能是某种 Hash */ var buildVersionId = "XXD9E43D7A1EBB6669597650E3EE417D9E7F5" - - /** - * 客户端类型(似乎只有 H5 一种类型) - * 此属性在旧版客户端不存在 - */ - var channel = "html5_app_bili" - - /** - * 客户端平台 - */ - var platform = "android" } diff --git a/src/main/kotlin/com/hiczp/bilibili/api/passport/PassportAPI.kt b/src/main/kotlin/com/hiczp/bilibili/api/passport/PassportAPI.kt new file mode 100644 index 0000000..d2716af --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/passport/PassportAPI.kt @@ -0,0 +1,18 @@ +package com.hiczp.bilibili.api.passport + +import com.hiczp.bilibili.api.passport.model.GetKeyResponse +import com.hiczp.bilibili.api.passport.model.LoginResponse +import kotlinx.coroutines.Deferred +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +@Suppress("DeferredIsResult") +interface PassportAPI { + @POST("/api/oauth2/getKey") + fun getKey(): Deferred + + @POST("/api/v3/oauth2/login") + @FormUrlEncoded + fun login(@Field("username") username: String, @Field("password") password: String): Deferred +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/passport/model/GetKeyResponse.kt b/src/main/kotlin/com/hiczp/bilibili/api/passport/model/GetKeyResponse.kt new file mode 100644 index 0000000..2a9d959 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/passport/model/GetKeyResponse.kt @@ -0,0 +1,21 @@ +package com.hiczp.bilibili.api.passport.model + +import com.google.gson.annotations.SerializedName + +data class GetKeyResponse( + @SerializedName("code") + var code: Int, // 0 + @SerializedName("message") + var message: String, + @SerializedName("data") + var `data`: Data, + @SerializedName("ts") + var ts: Int // 1550219688 +) { + data class Data( + @SerializedName("hash") + var hash: String, // 93ac6f60b4789952 + @SerializedName("key") + var key: String // -----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCdScM09sZJqFPX7bvmB2y6i08JbHsa0v4THafPbJN9NoaZ9Djz1LmeLkVlmWx1DwgHVW+K7LVWT5FV3johacVRuV9837+RNntEK6SE82MPcl7fA++dmW2cLlAjsIIkrX+aIvvSGCuUfcWpWFy3YVDqhuHrNDjdNcaefJIQHMW+sQIDAQAB-----END PUBLIC KEY----- + ) +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/passport/model/LoginResponse.kt b/src/main/kotlin/com/hiczp/bilibili/api/passport/model/LoginResponse.kt new file mode 100644 index 0000000..b19862d --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/passport/model/LoginResponse.kt @@ -0,0 +1,54 @@ +package com.hiczp.bilibili.api.passport.model + +import com.google.gson.annotations.SerializedName + +data class LoginResponse( + @SerializedName("code") + var code: Int, // 0 + @SerializedName("data") + var `data`: Data, + @SerializedName("message") + var message: String, + @SerializedName("ts") + var ts: Int // 1550219689 +) { + data class Data( + @SerializedName("cookie_info") + var cookieInfo: CookieInfo, + @SerializedName("sso") + var sso: List, + @SerializedName("status") + var status: Int, // 0 + @SerializedName("token_info") + var tokenInfo: TokenInfo + ) { + data class CookieInfo( + @SerializedName("cookies") + var cookies: List, + @SerializedName("domains") + var domains: List + ) { + data class Cookie( + @SerializedName("expires") + var expires: Int, // 1552811689 + @SerializedName("http_only") + var httpOnly: Boolean, // 1 + @SerializedName("name") + var name: String, // SESSDATA + @SerializedName("value") + var value: String // 5ff9ba24%2C1552811689%2C04ae9421 + ) + } + + data class TokenInfo( + @SerializedName("access_token") + var accessToken: String, // fd0303ff75a6ec6b452c28f4d8621021 + @SerializedName("expires_in") + var expiresIn: Int, // 2592000 + @SerializedName("mid") + var mid: Int, // 20293030 + @SerializedName("refresh_token") + var refreshToken: String // 6a333ebded3c3dbdde65d136b3190d21 + ) + } +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpMethod.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpMethod.kt new file mode 100644 index 0000000..9d4e966 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpMethod.kt @@ -0,0 +1,10 @@ +package com.hiczp.bilibili.api.retrofit + +enum class HttpMethod { + GET, + POST, + PATCH, + PUT, + DELETE, + OPTION +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/RetrofitExtension.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/RetrofitExtension.kt new file mode 100644 index 0000000..fab7e47 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/RetrofitExtension.kt @@ -0,0 +1,34 @@ +package com.hiczp.bilibili.api.retrofit + +import okhttp3.FormBody + +inline fun FormBody.forEach(block: (Pair) -> Unit) { + for (i in 0..size()) { + block(encodedName(i) to encodedValue(i)) + } +} + +fun FormBody.raw() = + StringBuilder().apply { + repeat(size()) { + if (it != 0) append('&') + append(encodedName(it)) + append('=') + append(encodedValue(it)) + } + }.toString() + +fun FormBody.sortedRaw(): String { + val nameAndValue = ArrayList() + repeat(size()) { + nameAndValue.add("${encodedName(it)}=${encodedValue(it)}") + } + return nameAndValue.sorted().joinToString(separator = "&") +} + +fun FormBody.Builder.addAll(formBody: FormBody): FormBody.Builder { + formBody.forEach { (encodedName, encodedValue) -> + addEncoded(encodedName, encodedValue) + } + return this +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonHeaderInterceptor.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonHeaderInterceptor.kt new file mode 100644 index 0000000..fe9ffa0 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonHeaderInterceptor.kt @@ -0,0 +1,20 @@ +package com.hiczp.bilibili.api.retrofit.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * 为请求添加公共 Header + * + * @param additionHeaders HeaderName to HeaderValueExpression + */ +class CommonHeaderInterceptor(private vararg val additionHeaders: Pair String>) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder().apply { + additionHeaders.forEach { (headerName, headerValueExpression) -> + addHeader(headerName, headerValueExpression()) + } + }.build() + return chain.proceed(request) + } +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonParamInterceptor.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonParamInterceptor.kt new file mode 100644 index 0000000..dc7f985 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/CommonParamInterceptor.kt @@ -0,0 +1,59 @@ +package com.hiczp.bilibili.api.retrofit.interceptor + +import com.hiczp.bilibili.api.retrofit.HttpMethod +import com.hiczp.bilibili.api.retrofit.addAll +import mu.KotlinLogging +import okhttp3.FormBody +import okhttp3.Interceptor +import okhttp3.Response + +private val logger = KotlinLogging.logger {} + +/** + * 为请求添加公共参数 + * + * @param paramType 参数类型, Query 或 FormUrlEncoded + * @param additionParams ParamName to ParamValueExpression + */ +class CommonParamInterceptor( + private val paramType: ParamType, + private vararg val additionParams: Pair String> +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (paramType == ParamType.QUERY) { + val httpUrl = request.url().newBuilder().apply { + additionParams.forEach { (paramName, paramValueExpression) -> + addQueryParameter(paramName, paramValueExpression()) + } + }.build() + return chain.proceed( + request.newBuilder().url(httpUrl).build() + ) + } else if (paramType == ParamType.FORM_URL_ENCODED && request.body() is FormBody || + paramType == ParamType.FORM_URL_ENCODED && request.method() == HttpMethod.POST.toString() && request.body()?.contentType() == null + ) { + //TODO 原有的 Body 为空的情况 + val formBody = FormBody.Builder().apply { + //添加原有的参数 + addAll(request.body() as FormBody) + //添加公共参数 + additionParams.forEach { (paramName, paramValueExpression) -> + add(paramName, paramValueExpression()) + } + }.build() + return chain.proceed( + request.newBuilder().post(formBody).build() + ) + } else { + //如果 body 不为 FormBody 将无法添加 FormUrlEncoded 参数 + logger.error { + "Impossible add FormUrlEncoded params to ${request.body()?.javaClass}. Request: " + + "${request.method()} ${request.url()}" + } + } + + return chain.proceed(request) + } +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/ParamType.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/ParamType.kt new file mode 100644 index 0000000..786037f --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/ParamType.kt @@ -0,0 +1,6 @@ +package com.hiczp.bilibili.api.retrofit.interceptor + +enum class ParamType { + QUERY, + FORM_URL_ENCODED +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt new file mode 100644 index 0000000..49f9adb --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt @@ -0,0 +1,66 @@ +package com.hiczp.bilibili.api.retrofit.interceptor + +import com.hiczp.bilibili.api.retrofit.addAll +import com.hiczp.bilibili.api.retrofit.sortedRaw +import mu.KotlinLogging +import okhttp3.FormBody +import okhttp3.Interceptor +import okhttp3.Response +import java.security.MessageDigest + +private val logger = KotlinLogging.logger {} + +/** + * 排序参数并添加签名 + */ +class SortAndSignInterceptor(private val paramType: ParamType, private val appSecret: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (paramType == ParamType.QUERY) { + //这里认为 Query 一定不为 null + val sortedEncodedQuery = request.url().encodedQuery()!! + .split('&') + .sorted() + .joinToString(separator = "&") + val sign = calculateSign(sortedEncodedQuery, appSecret) + return chain.proceed( + request.newBuilder() + .url(request.url().newBuilder() + .encodedQuery("$sortedEncodedQuery&sign=$sign") + .build() + ) + .build() + ) + } else if (paramType == ParamType.FORM_URL_ENCODED && request.body() is FormBody) { + val sign = calculateSign((request.body() as FormBody).sortedRaw(), appSecret) + val formBody = FormBody.Builder().apply { + //添加原有的参数 + addAll(request.body() as FormBody) + //添加 sign + addEncoded("sign", sign) + }.build() + return chain.proceed( + request.newBuilder().post(formBody).build() + ) + } else { + //如果 body 不为 FormBody 将无法添加签名 + logger.error { + "Impossible add sign to ${request.body()?.javaClass}. Request: " + + "${request.method()} ${request.url()}" + } + } + + return chain.proceed(request) + } + + /** + * 签名算法为 "$排序后的参数字符串$appSecret".md5() + */ + private fun calculateSign(string: String, appSecret: String) = + MessageDigest.getInstance("MD5") + .digest((string + appSecret).toByteArray()) + .joinToString(separator = "") { + String.format("%02x", it) + } +}