All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.hiczp.bilibili.api.BilibiliClient.kt Maven / Gradle / Ivy

There is a newer version: 0.2.0
Show newest version
package com.hiczp.bilibili.api

import com.hiczp.bilibili.api.app.AppAPI
import com.hiczp.bilibili.api.danmaku.DanmakuAPI
import com.hiczp.bilibili.api.live.LiveAPI
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
import com.hiczp.bilibili.api.passport.model.LoginResponse
import com.hiczp.bilibili.api.player.PlayerAPI
import com.hiczp.bilibili.api.player.PlayerInterceptor
import com.hiczp.bilibili.api.retrofit.Header
import com.hiczp.bilibili.api.retrofit.Param
import com.hiczp.bilibili.api.retrofit.exception.BilibiliApiException
import com.hiczp.bilibili.api.retrofit.interceptor.CommonHeaderInterceptor
import com.hiczp.bilibili.api.retrofit.interceptor.CommonParamInterceptor
import com.hiczp.bilibili.api.retrofit.interceptor.FailureResponseInterceptor
import com.hiczp.bilibili.api.retrofit.interceptor.SortAndSignInterceptor
import com.hiczp.bilibili.api.vc.VcAPI
import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
import okhttp3.ConnectionPool
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.security.KeyFactory
import java.security.spec.X509EncodedKeySpec
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.*
import javax.crypto.Cipher

/**
 * 此类表示一个模拟的 Bilibili 客户端(Android), 所有调用由此开始.
 * 多个 BilibiliClient 实例之间不共享登陆状态.
 * 不能严格保证线程安全.
 *
 * @param billingClientProperties 客户端的固有属性, 是一种常量
 * @param logLevel 日志打印等级
 */
@Suppress("unused")
class BilibiliClient(
        @Suppress("MemberVisibilityCanBePrivate")
        val billingClientProperties: BilibiliClientProperties = BilibiliClientProperties(),
        private val logLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.NONE
) {
    /**
     * 客户端被打开的时间(BilibiliClient 被实例化的时间)
     */
    private val initTime = Instant.now().epochSecond

    /**
     * 登陆操作得到的 Response
     */
    var loginResponse: LoginResponse? = null

    /**
     * 是否已登录
     */
    val isLogin
        get() = loginResponse != null

    //快捷方式
    @Suppress("MemberVisibilityCanBePrivate")
    val userId
        get() = loginResponse?.userId
    @Suppress("MemberVisibilityCanBePrivate")
    val token
        get() = loginResponse?.token

    @Suppress("SpellCheckingInspection")
    private val defaultCommonHeaderInterceptor = CommonHeaderInterceptor(
            Header.DISPLAY_ID to { "${billingClientProperties.buildVersionId}-$initTime" },
            Header.BUILD_VERSION_ID to { billingClientProperties.buildVersionId },
            Header.USER_AGENT to { billingClientProperties.defaultUserAgent },
            Header.DEVICE_ID to { billingClientProperties.hardwareId }
    )

    @Suppress("SpellCheckingInspection")
    private val defaultCommonParamArray = arrayOf(
            Param.ACCESS_KEY to { token },
            Param.APP_KEY to { billingClientProperties.appKey },
            Param.BUILD to { billingClientProperties.build },
            Param.CHANNEL to { billingClientProperties.channel },
            Param.MOBILE_APP to { billingClientProperties.platform },
            Param.PLATFORM to { billingClientProperties.platform },
            Param.TIMESTAMP to { Instant.now().epochSecond.toString() }
    )

    private val defaultCommonParamInterceptor = CommonParamInterceptor(*defaultCommonParamArray)

    /**
     * 用户鉴权相关的接口
     */
    @Suppress("SpellCheckingInspection")
    val passportAPI by lazy {
        createAPI(BaseUrl.passport,
                defaultCommonHeaderInterceptor,
                CommonParamInterceptor(
                        Param.APP_KEY to { billingClientProperties.appKey },
                        Param.BUILD to { billingClientProperties.build },
                        Param.CHANNEL to { billingClientProperties.channel },
                        Param.MOBILE_APP to { billingClientProperties.platform },
                        Param.PLATFORM to { billingClientProperties.platform },
                        Param.TIMESTAMP to { Instant.now().epochSecond.toString() }
                )
        )
    }

    /**
     * 消息通知有关的接口
     */
    @Suppress("SpellCheckingInspection")
    val messageAPI by lazy {
        createAPI(BaseUrl.message,
                defaultCommonHeaderInterceptor,
                CommonParamInterceptor(*defaultCommonParamArray,
                        Param.ACTION_KEY to { Param.APP_KEY },
                        "has_up" to { "1" }
                )
        )
    }

    /**
     * 总站 API
     */
    @Suppress("SpellCheckingInspection")
    val appAPI by lazy {
        createAPI(BaseUrl.app,
                defaultCommonHeaderInterceptor,
                defaultCommonParamInterceptor
        )
    }

    /**
     * 这也是总站 API
     */
    @Suppress("SpellCheckingInspection")
    val mainAPI by lazy {
        createAPI(BaseUrl.main,
                CommonHeaderInterceptor(
                        //如果未登陆则没有 Display-ID
                        Header.DISPLAY_ID to { userId?.let { "$it-$initTime" } },
                        Header.BUILD_VERSION_ID to { billingClientProperties.buildVersionId },
                        Header.USER_AGENT to { billingClientProperties.defaultUserAgent },
                        Header.DEVICE_ID to { billingClientProperties.hardwareId }
                ),
                defaultCommonParamInterceptor
        )
    }

    /**
     * 小视频相关接口
     */
    @Suppress("SpellCheckingInspection")
    val vcAPI by lazy {
        createAPI(BaseUrl.vc,
                defaultCommonHeaderInterceptor,
                CommonParamInterceptor(*defaultCommonParamArray,
                        Param._DEVICE to { billingClientProperties.platform },
                        Param._HARDWARE_ID to { billingClientProperties.hardwareId },
                        Param.SOURCE to { billingClientProperties.channel },
                        Param.TRACE_ID to { generateTraceId() },
                        Param.USER_ID to { userId?.toString() },
                        Param.VERSION to { billingClientProperties.version }
                )
        )
    }

    /**
     * 创作中心
     */
    val memberAPI by lazy {
        createAPI(BaseUrl.member,
                defaultCommonHeaderInterceptor,
                defaultCommonParamInterceptor
        )
    }

    /**
     * 播放器所需的 API, 用于获取视频播放地址
     */
    val playerAPI: PlayerAPI by lazy {
        Retrofit.Builder()
                .baseUrl("https://bilibili.com")    //这里的 baseUrl 是没用的
                .addConverterFactory(gsonConverterFactory)
                .addCallAdapterFactory(coroutineCallAdapterFactory)
                .client(OkHttpClient.Builder().apply {
                    addInterceptor(PlayerInterceptor(billingClientProperties) { loginResponse })
                    addInterceptor(FailureResponseInterceptor)
                    addNetworkInterceptor(httpLoggingInterceptor)
                    connectionPool(connectionPool)
                }.build())
                .build()
                .create(PlayerAPI::class.java)
    }

    /**
     * 获取弹幕所用的 API
     */
    val danmakuAPI: DanmakuAPI by lazy {
        Retrofit.Builder()
                .baseUrl(BaseUrl.main)
                .addCallAdapterFactory(coroutineCallAdapterFactory)
                .client(OkHttpClient.Builder().apply {
                    addInterceptor(CommonHeaderInterceptor(
                            Header.ACCEPT to { "application/xhtml+xml,application/xml" },
                            Header.ACCEPT_ENCODING to { "gzip, deflate" },
                            Header.USER_AGENT to { billingClientProperties.defaultUserAgent }
                    ))
                    addInterceptor(defaultCommonParamInterceptor)
                    addInterceptor(sortAndSignInterceptor)
                    addNetworkInterceptor(httpLoggingInterceptor)
                    connectionPool(connectionPool)
                }.build())
                .build()
                .create(DanmakuAPI::class.java)
    }

    /**
     * 直播站
     */
    val liveAPI by lazy {
        createAPI(BaseUrl.live,
                CommonHeaderInterceptor(
                        //如果未登陆则没有 Display-ID
                        Header.DISPLAY_ID to { userId?.let { "$it-$initTime" } },
                        Header.BUILD_VERSION_ID to { billingClientProperties.buildVersionId },
                        Header.USER_AGENT to { billingClientProperties.defaultUserAgent },
                        Header.DEVICE_ID to { billingClientProperties.hardwareId }
                ),
                CommonParamInterceptor(*defaultCommonParamArray,
                        Param.ACTION_KEY to { Param.APP_KEY },
                        Param.DEVICE to { billingClientProperties.platform }
                )
        )
    }

    /**
     * 登陆
     * v3 登陆接口会同时返回 cookies 和 token
     * 如果要求验证码, 访问 data 中提供的 url 将打开一个弹窗, 里面会加载 js 并显示极验
     * 极验会调用 https://api.geetest.com/ajax.php 上传滑动轨迹, 然后获得 validate 的值
     * secCode 的值为 "$validate|jordan"
     *
     * @throws BilibiliApiException 用户名与密码不匹配(-629)或者需要验证码(极验)(-105)
     */
    @Throws(BilibiliApiException::class)
    suspend fun login(
            username: String, password: String,
            //如果登陆请求返回了 "验证码错误!"(-105) 的结果, 那么下一次发送登陆请求就需要带上验证码
            challenge: String? = null,
            secCode: String? = null,
            validate: String? = null
    ): LoginResponse {
        //取得 hash 和 RSA 公钥
        val (hash, key) = passportAPI.getKey().await().data.let { data ->
            data.hash to data.key.split('\n').filterNot { it.startsWith('-') }.joinToString(separator = "")
        }

        //解析 RSA 公钥
        val publicKey = X509EncodedKeySpec(Base64.getDecoder().decode(key)).let {
            KeyFactory.getInstance("RSA").generatePublic(it)
        }
        //加密密码
        //兼容 Android
        val cipheredPassword = Cipher.getInstance("RSA/ECB/PKCS1Padding").apply {
            init(Cipher.ENCRYPT_MODE, publicKey)
        }.doFinal((hash + password).toByteArray()).let {
            Base64.getEncoder().encode(it)
        }.let {
            String(it)
        }

        return passportAPI.login(username, cipheredPassword, challenge, secCode, validate).await().also {
            this.loginResponse = it
        }
    }

    /**
     * 登出
     * 这个方法不一定是线程安全的, 登出的同时如果进行登陆操作可能引发错误
     */
    suspend fun logout() {
        val response = loginResponse ?: return
        val cookieMap = response.data.cookieInfo.cookies
                .associate {
                    it.name to it.value
                }
        passportAPI.revoke(cookieMap, response.token).await()
        loginResponse = null
    }

    private val sortAndSignInterceptor = SortAndSignInterceptor(billingClientProperties.appSecret)
    private val httpLoggingInterceptor = HttpLoggingInterceptor().setLevel(logLevel)
    private inline fun  createAPI(
            baseUrl: String,
            vararg interceptors: Interceptor
    ) = Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(gsonConverterFactory)
            .addCallAdapterFactory(coroutineCallAdapterFactory)
            .client(OkHttpClient.Builder().apply {
                interceptors.forEach {
                    addInterceptor(it)
                }
                addInterceptor(sortAndSignInterceptor)
                addInterceptor(FailureResponseInterceptor)
                addNetworkInterceptor(httpLoggingInterceptor)
                connectionPool(connectionPool)
            }.build())
            .build()
            .create(T::class.java)

    companion object {
        @Suppress("SpellCheckingInspection")
        private val gsonConverterFactory = GsonConverterFactory.create()
        private val coroutineCallAdapterFactory = CoroutineCallAdapterFactory()
        private val connectionPool = ConnectionPool()
        private val traceIdFormat = SimpleDateFormat("yyyyMMddHHmm000ss")
        private fun generateTraceId() = traceIdFormat.format(Date())
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy