This commit is contained in:
czp3009 2020-10-04 14:26:32 +08:00
parent b4e482e78f
commit b17cbe6f03
66 changed files with 621 additions and 2308 deletions

101
README.md
View File

@ -1,104 +1,9 @@
# Bilibili API library for Kotlin
本项目封装一些 Bilibili API 以方便在 Kotlin 中使用(也可用于其他 JVM 语言).
协议来自对 Bilibili Android APP 的逆向工程以及截包分析.
在 Kotlin 中调用 Bilibili API.
# 引入依赖
RestFul API
```groovy
compile group: 'com.hiczp', name: 'bilibili-api-rest', version: '0.2.0'
```
协议来自 Bilibili Android APP.
Websocket(用于获取直播间实时弹幕)
```groovy
compile group: 'com.hiczp', name: 'bilibili-api-websocket', version: '0.2.0'
```
# RestFul API
大部分 API 都是 RestFul API.
`BilibiliClient` 是一个模拟的客户端, 内含登陆状态. 应持有其引用, 并在合适时执行 `close()`.
## 登陆
```kotlin
val bilibiliClient = BilibiliClient()
runBlocking {
bilibiliClient.login(username, password)
}
```
`BilibiliClient.login` 会返回一个 `Credential` 实例. 将其序列化后保存. 下次可以直接使用这一凭证来恢复登陆状态
```kotlin
val bilibiliClient = BilibiliClient(credential)
```
多次错误的登陆将导致下一次登陆需要验证码(极验).
登陆失败将抛出 `LoginException` 异常, 其中包含服务器原始返回内容 `LoginResponse`.
## 登出
```kotlin
runBlocking {
bilibiliClient.revoke()
}
```
`BilibiliClient.revoke` 返回已被注销的凭证, 或返回 `null` 当此 `BilibiliClient` 实例没有包含凭证时.
如果凭证是错误的, 将抛出 `RevokeException`.
# 获取直播间实时弹幕
直播间实时弹幕是一个 Websocket.
`LiveClient` 是一个模拟的 Websocket 客户端, 内含回调函数, 可以重复调用 `connect()` 函数.
举个例子
```kotlin
val liveClient = LiveClient(roomId = 23058) {
resolvedPackets.consumeEach {
when (it) {
is CommandPacket -> {
val command = it.content
println("[${command.cmd}] $command")
}
is PopularityPacket -> {
println("Popularity: ${it.content}")
}
}
}
}
runBlocking {
liveClient.connect()
}
```
`resolvedPackets` 是一个输送解析后的数据包的 `Channel`, 解析后的数据包有 `CommandPacket` 以及 `PopularityPacket` 两种类型.
`CommandPacket` 的本体是一段 JSON, 由于其内容经常发生改变, 所以不提供 POJO.
`Command.cmd` 是一个快捷方式, 可以快速取得其中的 `cmd` 字段从而判断其类别.
作为样本的 JSON 数据在本项目 `/record` 文件夹下.
大部分 JSON 都有光怪陆离的数据结构. 其中 `DANMU_MSG` 尤为恶劣, 通篇都是数组. 为了方便对其解析, 故提供内联类 `DanmakuMessage`.
使用方法如下
```kotlin
val command = commandPacket.content
if (command.cmd == "DANMU_MSG") {
with(command.asDanmakuMessage()) {
println("$nickname: $message")
}
}
```
`PopularityPacket` 的本体是一个 `Int` 数字, 表示当前房间的人气值. 该数据包每 30秒 收到一次.
如果要为读取操作设定超时, 可以设定为 40秒.
注意: 如果使用短房间号来连接弹幕推送服务器, 可能会得不到正确的人气值信息(一直为 0 或者一直为 1). 因此在连接弹幕推送服务器前应当首先获取直播间基本信息. 同时, 弹幕服务器不是唯一的, 在构造 `LiveClient` 时可以传入其他服务器地址.
# Gradle
# License
Apache License 2.0

View File

@ -1,10 +1,16 @@
buildscript {
ext {
project_version = '0.2.0'
kotlin_version = '1.3.41'
kotlin_coroutines_version = '1.2.2'
project_version = '0.3.0'
kotlin_version = '1.3.71'
kotlin_coroutines_version = '1.3.5'
ktor_version = '1.3.1'
caeruleum_version = '1.2.9'
gson_version = '2.8.6'
kotson_version = '2.5.0'
kotlin_logging_version = '1.7.8'
slf4j_version = '1.7.30'
junit_version = '5.6.1'
jvm_target = JavaVersion.VERSION_1_8
ktor_version = '1.2.2'
}
repositories {
@ -16,131 +22,142 @@ buildscript {
}
}
allprojects {
group = 'com.hiczp'
version = project_version
description = 'Bilibili API library for Kotlin'
group = 'com.hiczp'
version = project_version
description = 'Bilibili API library for Kotlin'
apply plugin: 'maven-publish'
apply plugin: 'signing'
apply plugin: 'kotlin'
apply plugin: 'kotlin'
apply plugin: 'maven-publish'
apply plugin: 'signing'
repositories {
mavenCentral()
jcenter()
maven { url = 'https://dl.bintray.com/kotlin/ktor/' }
mavenLocal()
}
}
subprojects {
//kotlin
dependencies {
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib-jdk8
api group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
api group: 'org.jetbrains.kotlin', name: 'kotlin-reflect'
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
api group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: kotlin_coroutines_version
}
compileKotlin {
kotlinOptions {
jvmTarget = jvm_target
freeCompilerArgs = ["-Xjvm-default=enable", "-Xuse-experimental=kotlin.Experimental", "-XXLanguage:+InlineClasses"]
}
}
compileTestKotlin {
kotlinOptions {
jvmTarget = jvm_target
freeCompilerArgs = ["-Xjvm-default=enable", "-Xuse-experimental=kotlin.Experimental", "-XXLanguage:+InlineClasses"]
}
}
//logging
dependencies {
// https://mvnrepository.com/artifact/io.github.microutils/kotlin-logging
api group: 'io.github.microutils', name: 'kotlin-logging', version: '1.6.26'
// https://mvnrepository.com/artifact/org.slf4j/slf4j-simple
testApi group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.26'
}
//unit test
dependencies {
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter
testApi group: 'org.junit.jupiter', name: 'junit-jupiter', version: '5.4.2'
}
task sourcesJar(type: Jar) {
from sourceSets.main.allSource
archiveClassifier = 'sources'
}
task javadocJar(type: Jar) {
from javadoc
archiveClassifier = 'javadoc'
}
publishing {
def moduleName = "${rootProject.name}-${project.name}"
repositories {
maven {
url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
credentials {
username = project.properties.ossUsername
password = project.properties.ossPassword
}
}
}
publications {
mavenJava(MavenPublication) {
from components.java
artifact sourcesJar
artifact javadocJar
artifactId moduleName
pom {
name = moduleName
description = project.description
url = 'https://github.com/czp3009/bilibili-api'
licenses {
license {
name = 'GNU GENERAL PUBLIC LICENSE Version 3'
url = 'https://www.gnu.org/licenses/gpl-3.0.txt'
}
}
developers {
developer {
id = 'czp3009'
name = 'czp3009'
email = 'czp3009@gmail.com'
url = 'https://www.hiczp.com'
}
}
scm {
connection = 'scm:git:git://github.com/czp3009/bilibili-api.git'
developerConnection = 'scm:git:ssh://github.com/czp3009/bilibili-api.git'
url = 'https://github.com/czp3009/bilibili-api'
}
}
}
}
}
signing {
sign publishing.publications.mavenJava
}
repositories {
mavenCentral()
jcenter()
mavenLocal()
}
//kotlin
dependencies {
api project(':rest')
api project(':websocket')
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib-jdk8
api group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
api group: 'org.jetbrains.kotlin', name: 'kotlin-reflect'
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
api group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: kotlin_coroutines_version
}
compileKotlin {
kotlinOptions {
jvmTarget = jvm_target
freeCompilerArgs = ['-Xopt-in=kotlin.RequiresOptIn']
}
}
compileTestKotlin {
kotlinOptions {
jvmTarget = jvm_target
}
}
jar {
enabled = false
//logging
dependencies {
// https://mvnrepository.com/artifact/io.github.microutils/kotlin-logging
api group: 'io.github.microutils', name: 'kotlin-logging', version: kotlin_logging_version
// https://mvnrepository.com/artifact/org.slf4j/slf4j-simple
testApi group: 'org.slf4j', name: 'slf4j-simple', version: slf4j_version
}
//unit test
dependencies {
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter
testApi group: 'org.junit.jupiter', name: 'junit-jupiter', version: junit_version
}
//http
dependencies {
// https://mvnrepository.com/artifact/com.hiczp/caeruleum
api group: 'com.hiczp', name: 'caeruleum', version: caeruleum_version
}
//ktor
dependencies {
// https://mvnrepository.com/artifact/io.ktor/ktor-client-core
api group: 'io.ktor', name: 'ktor-client-core', version: ktor_version
// https://mvnrepository.com/artifact/io.ktor/ktor-client-websockets
api group: 'io.ktor', name: 'ktor-client-websockets', version: ktor_version
// https://mvnrepository.com/artifact/io.ktor/ktor-client-logging-jvm
api group: 'io.ktor', name: 'ktor-client-logging-jvm', version: ktor_version
// https://mvnrepository.com/artifact/io.ktor/ktor-client-gson
api group: 'io.ktor', name: 'ktor-client-gson', version: ktor_version
}
//json
dependencies {
// https://mvnrepository.com/artifact/com.google.code.gson/gson
api group: 'com.google.code.gson', name: 'gson', version: gson_version
// https://mvnrepository.com/artifact/com.github.salomonbrys.kotson/kotson
api group: 'com.github.salomonbrys.kotson', name: 'kotson', version: kotson_version
}
task sourcesJar(type: Jar) {
from sourceSets.main.allSource
archiveClassifier = 'sources'
}
task javadocJar(type: Jar) {
from javadoc
archiveClassifier = 'javadoc'
}
publishing {
repositories {
maven {
url = "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
credentials {
username = project.properties.ossUsername
password = project.properties.ossPassword
}
}
}
publications {
mavenJava(MavenPublication) {
from components.java
artifact sourcesJar
artifact javadocJar
pom {
name = project.name
description = project.description
url = 'https://github.com/czp3009/bilibili-api'
licenses {
license {
name = 'GNU GENERAL PUBLIC LICENSE Version 3'
url = 'https://www.gnu.org/licenses/gpl-3.0.txt'
}
}
developers {
developer {
id = 'czp3009'
name = 'czp3009'
email = 'czp3009@gmail.com'
url = 'https://www.hiczp.com'
}
}
scm {
connection = 'scm:git:git://github.com/czp3009/bilibili-api.git'
developerConnection = 'scm:git:ssh://github.com/czp3009/bilibili-api.git'
url = 'https://github.com/czp3009/bilibili-api'
}
}
}
}
}
signing {
sign publishing.publications.mavenJava
}
gradle.taskGraph.whenReady { taskGraph ->
tasks.signMavenJavaPublication.onlyIf { taskGraph.hasTask tasks.publish }
}

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip

View File

@ -1,14 +0,0 @@
{
"cmd": "COMBO_END",
"data": {
"uname": "by_a_second",
"r_uname": "黑桐谷歌",
"combo_num": 3,
"price": 3000,
"gift_name": "给代打的礼物",
"gift_id": 30051,
"start_time": 1553410146,
"end_time": 1553410148,
"guard_level": 0
}
}

View File

@ -1,12 +0,0 @@
{
"cmd": "COMBO_SEND",
"data": {
"uid": 16811396,
"uname": "by_a_second",
"combo_num": 3,
"gift_name": "给代打的礼物",
"gift_id": 30051,
"action": "赠送",
"combo_id": "gift:combo_id:16811396:43536:30051:1553410146.471"
}
}

View File

@ -1,54 +0,0 @@
{
"cmd": "DANMU_MSG",
"info": [
[
0,
1,
25,
16750592,
1553368447,
1772673920,
0,
"169cc1f9",
0,
0,
0
],
"这头衔永久的?",
[
9973581,
"丧糕菌",
0,
1,
1,
10000,
1,
""
],
[
17,
"丧病",
"扎双马尾的丧尸",
48499,
16752445,
""
],
[
42,
0,
16746162,
13011
],
[
"title-198-1",
"title-198-1"
],
0,
0,
null,
{
"ts": 1553368447,
"ct": "98688F2F"
}
]
}

View File

@ -1,22 +0,0 @@
{
"cmd": "ENTRY_EFFECT",
"data": {
"id": 4,
"uid": 3007159,
"target_id": 43536,
"mock_effect": 0,
"face": "https://i0.hdslb.com/bfs/face/7c071f180a20512eba29e80bb13d1c8a3fe3916a.jpg",
"privilege_type": 3,
"copy_writing": "欢迎舰长 <%goodby...%> 进入直播间",
"copy_color": "",
"highlight_color": "#E6FF00",
"priority": 70,
"basemap_url": "https://i0.hdslb.com/bfs/live/1fa3cc06258e16c0ac4c209e2645fda3c2791894.png",
"show_avatar": 1,
"effective_time": 2,
"web_basemap_url": "",
"web_effective_time": 0,
"web_effect_close": 0,
"web_close_time": 0
}
}

View File

@ -1,14 +0,0 @@
{
"cmd": "GUARD_BUY",
"data": {
"uid": 1781654,
"username": "renbye",
"guard_level": 3,
"num": 1,
"price": 198000,
"gift_id": 10003,
"gift_name": "舰长",
"start_time": 1553429698,
"end_time": 1553429698
}
}

View File

@ -1,27 +0,0 @@
{
"cmd": "GUARD_LOTTERY_START",
"data": {
"id": 955580,
"roomid": 1029,
"message": "renbye 在【1029】购买了舰长请前往抽奖",
"type": "guard",
"privilege_type": 3,
"link": "https://live.bilibili.com/1029",
"payflow_id": "gds_74e19a449c1fdaaa73_201903",
"lottery": {
"id": 955580,
"sender": {
"uid": 1781654,
"uname": "renbye",
"face": "http://i1.hdslb.com/bfs/face/0b7a8be6e5d2a89a7de7ccd211a529599f03284e.jpg"
},
"keyword": "guard",
"privilege_type": 3,
"time": 1200,
"status": 1,
"mobile_display_mode": 2,
"mobile_static_asset": "",
"mobile_animation_asset": ""
}
}
}

View File

@ -1,9 +0,0 @@
{
"cmd": "GUARD_MSG",
"msg": "用户 :?鱼仔是橙子的小祖宗:? 在主播 鱼仔一点都不困 的直播间开通了总督",
"msg_new": "<%鱼仔是橙子的小祖宗%> 在 <%鱼仔一点都不困%> 的房间开通了总督并触发了抽奖点击前往TA的房间去抽奖吧",
"url": "https://live.bilibili.com/46744",
"roomid": 46744,
"buy_type": 1,
"broadcast_type": 0
}

View File

@ -1,37 +0,0 @@
{
"cmd": "NOTICE_MSG",
"full": {
"head_icon": "http://i0.hdslb.com/bfs/live/b29add66421580c3e680d784a827202e512a40a0.webp",
"tail_icon": "http://i0.hdslb.com/bfs/live/822da481fdaba986d738db5d8fd469ffa95a8fa1.webp",
"head_icon_fa": "http://i0.hdslb.com/bfs/live/49869a52d6225a3e70bbf1f4da63f199a95384b2.png",
"tail_icon_fa": "http://i0.hdslb.com/bfs/live/38cb2a9f1209b16c0f15162b0b553e3b28d9f16f.png",
"head_icon_fan": 24,
"tail_icon_fan": 4,
"background": "#66A74EFF",
"color": "#FFFFFFFF",
"highlight": "#FDFF2FFF",
"time": 20
},
"half": {
"head_icon": "http://i0.hdslb.com/bfs/live/ec9b374caec5bd84898f3780a10189be96b86d4e.png",
"tail_icon": "",
"background": "#85B971FF",
"color": "#FFFFFFFF",
"highlight": "#FDFF2FFF",
"time": 15
},
"side": {
"head_icon": "http://i0.hdslb.com/bfs/live/e41c7e12b1e08724d2ab2f369515132d30fe1ef7.png",
"background": "#F4FDE8FF",
"color": "#79B48EFF",
"highlight": "#388726FF",
"border": "#A9DA9FFF"
},
"roomid": 12124934,
"real_roomid": 12124934,
"msg_common": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船点击前往TA的房间去抽奖吧",
"msg_self": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船快来抽奖吧",
"link_url": "http://live.bilibili.com/12124934?live_lottery_type=1&broadcast_type=0&from=28003&extra_jump_from=28003",
"msg_type": 2,
"shield_uid": -1
}

View File

@ -1,11 +0,0 @@
{
"cmd": "ROOM_BLOCK_MSG",
"uid": 8305711,
"uname": "RMT0v0",
"data": {
"uid": 8305711,
"uname": "RMT0v0",
"operator": 1
},
"roomid": 1029
}

View File

@ -1,11 +0,0 @@
{
"cmd": "ROOM_RANK",
"data": {
"roomid": 1029,
"rank_desc": "单机小时榜 13",
"color": "#FB7299",
"h5_url": "https://live.bilibili.com/p/html/live-app-rankcurrent/index.html?is_live_half_webview=1&hybrid_half_ui=1,5,85p,70p,FFE293,0,30,100,10;2,2,320,100p,FFE293,0,30,100,0;4,2,320,100p,FFE293,0,30,100,0;6,5,65p,60p,FFE293,0,30,100,10;5,5,55p,60p,FFE293,0,30,100,10;3,5,85p,70p,FFE293,0,30,100,10;7,5,65p,60p,FFE293,0,30,100,10;&anchor_uid=43536&rank_type=master_realtime_area_hour&area_hour=1&area_v2_id=245&area_v2_parent_id=6",
"web_url": "https://live.bilibili.com/blackboard/room-current-rank.html?rank_type=master_realtime_area_hour&area_hour=1&area_v2_id=245&area_v2_parent_id=6",
"timestamp": 1553409901
}
}

View File

@ -1,8 +0,0 @@
{
"cmd": "ROOM_REAL_TIME_MESSAGE_UPDATE",
"data": {
"roomid": 23058,
"fans": 297141,
"red_notice": -1
}
}

View File

@ -1,9 +0,0 @@
{
"cmd": "ROOM_SILENT_ON",
"data": {
"type": "level",
"level": 20,
"second": -1
},
"roomid": 1029
}

View File

@ -1,44 +0,0 @@
{
"cmd": "SEND_GIFT",
"data": {
"giftName": "辣条",
"num": 62,
"uname": "萌萌哒熊宝宝",
"face": "http://i0.hdslb.com/bfs/face/33570159b6bf28e01249b80d3f9f05fa117779c1.jpg",
"guard_level": 0,
"rcost": 123266565,
"uid": 10007727,
"top_list": [],
"timestamp": 1553369191,
"giftId": 1,
"giftType": 0,
"action": "喂食",
"super": 0,
"super_gift_num": 0,
"price": 100,
"rnd": "940348243",
"newMedal": 0,
"newTitle": 0,
"medal": [],
"title": "",
"beatId": "",
"biz_source": "live",
"metadata": "",
"remain": 0,
"gold": 0,
"silver": 0,
"eventScore": 0,
"eventNum": 0,
"smalltv_msg": [],
"specialGift": null,
"notice_msg": [],
"capsule": null,
"addFollow": 0,
"effect_block": 1,
"coin_type": "silver",
"total_coin": 6200,
"effect": 0,
"tag_image": "",
"user_count": 0
}
}

View File

@ -1,14 +0,0 @@
{
"cmd": "SYS_MSG",
"msg": "小苏棠の大脸猫脸大:?送给:?小苏棠i:?1个小电视飞船点击前往TA的房间去抽奖吧",
"msg_text": "小苏棠の大脸猫脸大:?送给:?小苏棠i:?1个小电视飞船点击前往TA的房间去抽奖吧",
"msg_common": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船点击前往TA的房间去抽奖吧",
"msg_self": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船快来抽奖吧",
"rep": 1,
"styleType": 2,
"url": "http://live.bilibili.com/12124934",
"roomid": 12124934,
"real_roomid": 12124934,
"rnd": 1553410466,
"broadcast_type": 0
}

View File

@ -1,10 +0,0 @@
{
"cmd": "USER_TOAST_MSG",
"data": {
"op_type": 1,
"uid": 1781654,
"username": "renbye",
"guard_level": 3,
"is_show": 0
}
}

View File

@ -1,9 +0,0 @@
{
"cmd": "WELCOME",
"data": {
"uid": 3173595,
"uname": "百杜Paido",
"is_admin": false,
"svip": 1
}
}

View File

@ -1,8 +0,0 @@
{
"cmd": "WELCOME_GUARD",
"data": {
"uid": 3007159,
"username": "goodbyecaroline",
"guard_level": 3
}
}

View File

@ -1,132 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<i>
<!--aid 44514794-->
<oid>77932184</oid>
<ps>0</ps>
<pe>196000</pe>
<pc>1</pc>
<pn>1</pn>
<state>0</state>
<real_name>0</real_name>
<!--https://github.com/bilibili/DanmakuFlameMaster/blob/master/Sample/src/main/java/com/sample/BiliDanmukuParser.java 与现在版本不一致-->
<!--0: 弹幕 id-->
<!--1: 不明确(可能是弹幕池 id)-->
<!--2: 弹幕出现时间(播放器时间)(ms)-->
<!--3: 类型(1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)-->
<!--4: 字号-->
<!--5: 颜色-->
<!--6: 时间戳(弹幕的发送时间)-->
<!--7: 不明确-->
<!--8: 用户 id 的 hash(ITU I.363.5, 即 CRC32)-->
<d p="12509048833835076,0,117373,5,25,16777215,1551001292,0,d2c5fc5">硬核劈柴</d>
<d p="12509130082746372,0,10149,1,25,16777215,1551001447,0,36762f3f">2222</d>
<d p="12509469902635008,0,167322,1,25,16777215,1551002095,0,a54ebcbd">神奇的三哥,没有什么是他们顶不了的</d>
<d p="12509518345273348,0,99416,1,25,16777215,1551002187,0,fdfc821d">这水是甜的吧</d>
<d p="12509689741312004,0,143359,1,25,16777215,1551002514,0,d9141364">真 逃生</d>
<d p="12510325574729732,0,31553,1,25,16777215,1551003727,0,9be0083c">非常优秀</d>
<d p="12510425270190082,0,112103,1,25,16777215,1551003917,0,6b7e70e1">中国,赞</d>
<d p="12511511077453828,0,145335,1,25,16777215,1551005988,0,4640575b">逃离生活的窗</d>
<d p="12511622457196548,0,35862,1,25,16777215,1551006200,0,32249eeb">嘴冲?</d>
<d p="12511627372396548,0,38222,1,25,16777215,1551006210,0,32249eeb">口冲</d>
<d p="12515287960125440,0,33997,1,25,16777215,1551013192,0,31f305c2">自取其乳</d>
<d p="12515547915223040,0,168259,1,25,16777215,1551013688,0,5a09b397">666</d>
<d p="12516693915467780,0,177747,1,25,16777215,1551015874,0,eee14fa1">这脖子</d>
<d p="12516704495075332,0,177747,1,25,16777215,1551015894,0,eee14fa1">这脖子是铁打的吧</d>
<d p="12517097740435458,0,14248,1,25,16777215,1551016644,0,4acca5b2">333</d>
<d p="12517272835326020,0,54028,1,25,16777215,1551016978,0,d087ac22">666啊</d>
<d p="12517313871872002,0,84671,1,25,41194,1551017056,0,c27bae2f">四倍体草莓</d>
<d p="12521502397169668,0,152101,1,25,16777215,1551025045,0,d425ebe6">高层建筑通云梯的窗台</d>
<d p="12521739791630340,0,13170,1,25,16777215,1551025498,0,ae910367">3338</d>
<d p="12526503713046532,0,51669,1,25,16777215,1551034584,0,782f992b">哈哈</d>
<d p="12528652852396036,0,151912,1,25,16777215,1551038683,0,24780dca">你告诉我哪里有这么高的云梯</d>
<d p="12531482545356802,0,151162,4,25,15138834,1551044081,0,7ba95a84">逃离生活窗</d>
<d p="12534806507159556,0,174937,1,25,16777215,1551050421,0,23e291df">自重最少三百公斤的玩意顶在头上还能单手爬楼梯,这是人能做到的吗</d>
<d p="12535841481555968,0,28234,1,25,16777215,1551052395,0,463ae566">前功尽弃系列</d>
<d p="12536174091436036,0,187603,1,25,16777215,1551053029,0,2f9d3c2b">深圳会展中心?</d>
<d p="12536447369216000,0,25105,1,25,16777215,1551053550,0,c2dd98f1">求这女孩的的体重 马上</d>
<d p="12536668506030082,0,45319,1,25,16777215,1551053972,0,7095f6c8">蘸糖墩儿</d>
<d p="12536737092337668,0,97592,1,25,16777215,1551054103,0,24fc5da4">我们家的</d>
<d p="12537151663636480,0,52026,1,25,16777215,1551054894,0,85067ef">牛逼</d>
<d p="12537154059632644,0,56703,1,25,16777215,1551054898,0,85067ef">卧槽</d>
<d p="12537358213709826,0,147955,1,25,16777215,1551055288,0,c0f9fca3">生活重来窗</d>
<d p="12538283349770244,0,98274,1,25,16777215,1551057052,0,9089266e">糖墩儿那个,我们是老乡</d>
<d p="12540637177970692,0,53562,1,25,16777215,1551061542,0,630553e8">卧槽</d>
<d p="12541039516057604,0,172384,1,25,16777215,1551062309,0,18afe75f">上化佛他们能顶么?</d>
<d p="12541168076718084,0,87307,1,25,16777215,1551062554,0,87411221">九星虹梅</d>
<d p="12541180367601668,0,62312,1,25,16777215,1551062578,0,84bbd366">6666</d>
<d p="12541421086572548,0,124646,1,25,16777215,1551063037,0,89e08e5b">快看 是岳云鹏</d>
<d p="12541523945586692,0,58595,1,25,16777215,1551063233,0,89853200">军人nb</d>
<d p="12541681330552836,0,55322,1,25,16777215,1551063533,0,7fc58f5">这是练什么,你们成天笑印度人,敢不敢把这个给印度人看</d>
<d p="12541710838005764,0,69226,1,25,16777215,1551063589,0,919f1906">一辈子单身</d>
<d p="12541713491951620,0,85083,1,25,16777215,1551063595,0,7fc58f5">这tm成了灵芝了</d>
<d p="12541729957216256,0,81506,1,25,16777215,1551063626,0,c99a3c2d">要坚强</d>
<d p="12541757011525636,0,83999,1,25,16777215,1551063678,0,c574fa94">草莓:我控制不住我的生长</d>
<d p="12541766596034560,0,175567,1,25,16777215,1551063696,0,919f1906">铁头功</d>
<d p="12541807514615812,0,58938,1,25,16777215,1551063774,0,795d594d">站军姿,身体要前倾</d>
<d p="12541814231793668,0,6716,1,25,16777215,1551063787,0,159c9870">牛逼</d>
<d p="12542220192186372,0,52988,1,25,16777215,1551064561,0,18ccd294">这个上初中时被班主任罚站,就是在台阶上用脚尖站</d>
<d p="12542324742553604,0,57888,1,25,16777215,1551064760,0,aba65a57">小腿肚子疼</d>
<d p="12542490122911748,0,52502,1,25,16777215,1551065076,0,dc79c826">江科炸出来</d>
<d p="12542873415188484,0,4141,4,25,15138834,1551065807,0,4fca51a">一句卧槽行天下</d>
<d p="12542894059028484,0,36129,1,25,16777215,1551065846,0,e86f7dbe">禁止自娱自乐</d>
<d p="12542915894575108,0,59905,4,25,15138834,1551065888,0,4fca51a">一句卧槽行走天下</d>
<d p="12543067077738500,0,55463,1,25,16777215,1551066176,0,47c66d00">这个是真的难受!!!</d>
<d p="12543107687514116,0,18613,1,25,16777215,1551066254,0,cb4a9077">还有卧槽</d>
<d p="12543134014636032,0,148627,1,25,16777215,1551066304,0,c830b87b">逃出升天</d>
<d p="12543278968209412,0,51770,1,25,16777215,1551066580,0,6090150d">我也这么站过</d>
<d p="12543327467995140,0,76223,1,25,16777215,1551066673,0,fbb62223">除了牛逼还可以说盖帽</d>
<d p="12543367314931716,0,135928,5,25,16777215,1551066749,0,88f119f1">半挂</d>
<d p="12543439578595332,0,59536,1,25,16777215,1551066887,0,8665586e">一个下去一排倒</d>
<d p="12543474239799300,0,47028,1,25,16711680,1551066953,0,3b59d8ed">强迫症不能忍</d>
<d p="12543478613409796,0,120357,1,25,16777215,1551066961,0,8665586e">硬核劈材</d>
<d p="12543729190043652,0,58468,1,25,16777215,1551067439,0,2158075b">我们也这么站过</d>
<d p="12543770022641666,0,12159,1,25,16777215,1551067517,0,1b84016c">卧槽</d>
<d p="12543874514812932,0,54399,1,25,16777215,1551067716,0,b8e8864e">我大江科</d>
<d p="12544083492339716,0,27741,1,25,16777215,1551068115,0,42bd1786">功亏一篑</d>
<d p="12544113036492804,0,54983,1,25,16777215,1551068171,0,42bd1786">我只会说:卧槽</d>
<d p="12544135804223492,0,68683,1,25,16777215,1551068215,0,42bd1786">亲媳妇</d>
<d p="12544157188358148,0,86981,1,25,16777215,1551068256,0,42bd1786">手炉?</d>
<d p="12544171444797444,0,101377,1,25,16777215,1551068283,0,42bd1786">甜辣口的</d>
<d p="12544201995059204,0,121207,1,25,16777215,1551068341,0,42bd1786">这个是高手</d>
<d p="12544234053173252,0,151787,1,25,16777215,1551068402,0,42bd1786">逃离生命</d>
<d p="12544262875381764,0,167925,1,25,16777215,1551068457,0,42bd1786">摩托精?</d>
<d p="12544293120507908,0,35820,1,25,16777215,1551068515,0,54681ea3">公的</d>
<d p="12544313787940868,0,190580,1,25,16777215,1551068554,0,9a0c194e">想看三哥顶汽车</d>
<d p="12544316839821316,0,54579,1,25,16777215,1551068560,0,54681ea3">好了站5个小时</d>
<d p="12544374657777668,0,151059,1,25,16777215,1551068670,0,54681ea3">逃离生活的窗户</d>
<d p="12544396354387970,0,145747,5,25,16707842,1551068712,0,1c2904f3">众所周知,逃生=逃出生天=逃出,生天</d>
<d p="12544659980025860,0,163561,1,25,16777215,1551069215,0,dac77b12">金字塔是不是他们顶上去的</d>
<d p="12545464756862980,0,174150,1,25,16777215,1551070749,0,7927ad01">腰间盘突出了解一下</d>
<d p="12546357659172868,0,65518,1,25,16777215,1551072453,0,d1b711e0">俺也一样</d>
<d p="12546551898963972,0,11939,1,25,16777215,1551072823,0,37017ae0">可以说 卧槽</d>
<d p="12546668542033924,0,31087,1,25,16777215,1551073046,0,a81360fc">我了个大草</d>
<d p="12546701081444354,0,82303,1,25,16777215,1551073108,0,a81360fc">兄弟帽子不错</d>
<d p="12546720176013316,0,175796,1,25,16777215,1551073144,0,46c90882">梯子牛逼</d>
<d p="12547003792228356,0,66404,1,25,16777215,1551073685,0,359613b4">老子最讨厌女人了!滚!</d>
<d p="12547078239027200,0,117822,1,25,16777215,1551073827,0,d586fc8f">还有这种操作!?</d>
<d p="12547123676971012,0,159473,1,25,16777215,1551073914,0,e08d6d5e">被谁淹没不知所措</d>
<d p="12547149768163328,0,18573,1,25,16777215,1551073963,0,af799cfa">奈何本人无文化,一句卧槽走天下</d>
<d p="12547183239757828,0,138761,1,25,16777215,1551074027,0,8ab298ba">好心酸。</d>
<d p="12547367960051714,0,174706,1,25,16777215,1551074380,0,d66698a9">我顶不住</d>
<d p="12547463784693762,0,98486,1,25,16777215,1551074562,0,315e19b2">她有一个大胆的想法</d>
<d p="12547504946544642,0,105119,1,25,16777215,1551074641,0,2bb0a0d5">那个不是西葫芦么。。西葫芦甜的???</d>
<d p="12547560521072644,0,162119,1,25,16777215,1551074747,0,590724e4">舒服</d>
<d p="12547667420250116,0,11704,1,25,16777215,1551074951,0,2bf4452b">牛了个逼</d>
<d p="12547706826260482,0,9199,1,25,16777215,1551075026,0,ac40f732">还可以说卧槽</d>
<d p="12547714786525184,0,4020,1,25,16777215,1551075041,0,ac40f732">还可以说卧槽</d>
<d p="12547717997789188,0,48483,1,25,16777215,1551075047,0,3c0c3293">他说弯腰下去继续蘸酱么?</d>
<d p="12547798832513028,0,49654,1,25,16777215,1551075201,0,6655f47c"></d>
<d p="12547845801377794,0,168066,1,25,16777215,1551075291,0,51068b32">牛逼</d>
<d p="12548000418627588,0,114144,1,25,16777215,1551075586,0,dff175b5">水瓜</d>
<d p="12548114018205700,0,56942,1,25,16777215,1551075803,0,10f78b15">模型出了点问题</d>
<d p="12548138132832260,0,152683,1,25,16777215,1551075849,0,a914360c">逃出生天</d>
<d p="12548268299911172,0,188366,1,25,16777215,1551076097,0,50865f2c">阿三是真牛逼…</d>
<d p="12548273663377412,0,31422,1,25,16777215,1551076107,0,33e5f873">回首掏 惨不忍睹</d>
<d p="12548290776137728,0,58698,1,25,16777215,1551076140,0,124b4e53">帅啊</d>
<d p="12548323870769154,0,186805,1,25,16777215,1551076203,0,dbc23261">这孩子的弹跳力惊人</d>
<d p="12548337070768128,0,170612,1,25,16777215,1551076228,0,552b1633">这是高手</d>
<d p="12548341505720322,0,170612,1,25,16777215,1551076237,0,552b1633">这是高手</d>
<d p="12548375489544196,0,191208,1,25,16777215,1551076301,0,124b4e53">吉尼斯记录三哥就顶的车</d>
<d p="12548479186370564,0,170612,1,25,16777215,1551076499,0,552b1633">这是高手</d>
<d p="12548593167630340,0,87039,1,25,16777215,1551076717,0,b3367171">八倍体草莓</d>
</i>

Binary file not shown.

1
rest/.gitignore vendored
View File

@ -1 +0,0 @@
src/test/resources/config.json

View File

@ -1,27 +0,0 @@
//http
dependencies {
// https://mvnrepository.com/artifact/com.hiczp/caeruleum
api group: 'com.hiczp', name: 'caeruleum', version: '1.2.2'
}
//ktor
dependencies {
// https://mvnrepository.com/artifact/io.ktor/ktor-client-logging-jvm
api group: 'io.ktor', name: 'ktor-client-logging-jvm', version: ktor_version
// https://mvnrepository.com/artifact/io.ktor/ktor-client-gson
api group: 'io.ktor', name: 'ktor-client-gson', version: ktor_version
}
//json
dependencies {
// https://mvnrepository.com/artifact/com.google.code.gson/gson
api group: 'com.google.code.gson', name: 'gson', version: '2.8.5'
// https://mvnrepository.com/artifact/com.github.salomonbrys.kotson/kotson
api group: 'com.github.salomonbrys.kotson', name: 'kotson', version: '2.5.0'
}
//checksum
dependencies {
// https://mvnrepository.com/artifact/com.hiczp/crc32-crack
api group: 'com.hiczp', name: 'crc32-crack', version: '1.0'
}

View File

@ -1,144 +0,0 @@
package com.hiczp.bilibili.rest
import com.hiczp.bilibili.rest.ktor.*
import com.hiczp.bilibili.rest.service.app.AppService
import com.hiczp.bilibili.rest.service.live.LiveService
import com.hiczp.bilibili.rest.service.orThrow
import com.hiczp.bilibili.rest.service.passport.*
import com.hiczp.bilibili.rest.utils.isLazyInitialized
import com.hiczp.caeruleum.create
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.features.UserAgent
import io.ktor.client.features.defaultRequest
import io.ktor.client.features.json.GsonSerializer
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.request.headers
import io.ktor.http.ContentType
import io.ktor.util.KtorExperimentalAPI
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.updateAndGet
import mu.KotlinLogging
import java.io.Closeable
import java.time.Instant
private val logger = KotlinLogging.logger { }
@Suppress("MemberVisibilityCanBePrivate", "unused")
class BilibiliClient(credential: Credential? = null) : Closeable {
private val initTime = Instant.now().epochSecond
val credential = atomic(credential)
val loggedIn get() = credential.value != null
/**
* Login
*
* @return new credential
*/
@Throws(LoginException::class)
suspend fun login(
username: String, password: String,
//极验
challenge: String? = null,
secCode: String? = null,
validate: String? = null
) = credential.updateAndGet {
if (it != null) logger.warn { "Override credential: ${it.userId}" }
val (hash, key) = PassportService.getKey().let { response ->
response.hash to response.key.toDER()
}
val cipheredPassword = (hash + password).rsaEncrypt(key)
PassportService.login(
username, cipheredPassword,
challenge, secCode, validate
).orThrow(::LoginException).toCredential().also {
logger.debug { "Logged with userId ${it.userId}" }
}
}!!
/**
* Revoke credential
*
* @return return old credential, null if not logged in
*/
@Throws(RevokeException::class)
suspend fun revoke() = credential.getAndUpdate { oldCredential ->
if (oldCredential != null) {
PassportService.revoke(oldCredential.accessToken, oldCredential.cookieMap()).orThrow(::RevokeException)
logger.debug { "Token revoked: ${oldCredential.userId}" }
}
null
}
/**
* Get OAuth2 info
*
* @return null if not logged in
*/
suspend fun oauth2Info() = credential.value?.let {
PassportService.info(it.accessToken, it.cookieMap())
}
@UseExperimental(KtorExperimentalAPI::class)
@Suppress("SpellCheckingInspection")
private val commonClient by lazy {
HttpClient(CIO) {
install(UserAgent) {
agent = BilibiliClientInherent.userAgent
}
defaultRequest {
headers {
with(BilibiliClientInherent) {
appendMissing(
"Display-ID",
this@BilibiliClient.credential.value?.let {
"${it.userId}-$initTime"
} ?: "$buvid-$initTime"
)
appendMissing("Display-ID", "$buvid-$initTime")
appendMissing("Buvid", buvid)
appendMissing("Device-ID", hardwareId)
appendMissing("env", env)
appendMissing("APP-KEY", platform)
}
}
}
commonParams {
this@BilibiliClient.credential.value?.let {
appendMissing("access_key", it.accessToken)
}
with(BilibiliClientInherent) {
appendMissing("actionKey", actionKey)
appendMissing("build", build)
appendMissing("buvid", buvid)
appendMissing("channel", channel)
appendMissing("device", platform)
appendMissing("device_name", mobileModel)
appendMissing("mobi_app", platform)
appendMissing("platform", platform)
appendMissing("statistics", statistics)
appendMissing("ts", Instant.now().epochSecond.toString())
}
}
install(JsonFeature) {
serializer = GsonSerializer()
acceptContentTypes = listOf(
ContentType.Application.Json,
ContentType("text", "json")
)
}
logging { }
}
}
val appService by lazy { commonClient.create<AppService>() }
val liveService by lazy { commonClient.create<LiveService>() }
override fun close() {
if (::commonClient.isLazyInitialized()) {
commonClient.close()
}
}
}

View File

@ -1,125 +0,0 @@
package com.hiczp.bilibili.rest
/**
* 客户端固有属性
* 5.44.0
*/
@Suppress("MemberVisibilityCanBePrivate")
object BilibiliClientInherent {
/**
* 默认 UA, 用于大多数访问
*/
@Suppress("SpellCheckingInspection")
const val userAgent = "Mozilla/5.0 BiliDroid/5.44.0 (bbcallen@gmail.com)"
/**
* Android 平台的 appKey(该默认值为普通版客户端, 非概念版)
*/
const val appKey = "1d8b6e7d45233436"
/**
* 由反编译 so 文件得到的 appSecret, appKey 必须匹配
*/
@Suppress("SpellCheckingInspection")
const val appSecret = "560c52ccd288fed045859ed18bffd973"
/**
* 获取视频播放地址使用的 appKey, 与访问其他 RestFulAPI 所用的 appKey 不一致
*/
@Suppress("SpellCheckingInspection")
const val videoAppKey = "iVGUTjsxvpLeuDCf"
/**
* 获取视频播放地址所用的 appSecret
*/
@Suppress("SpellCheckingInspection")
const val videoAppSecret = "aHRmhWMLkdeMuILqORnYZocwMBpMEOdt"
/**
* 新版客户端出现的某种 ID
*/
const val appId = 1
/**
* 客户端平台
*/
const val platform = "android"
/**
* 客户端平台代码
*/
const val platformCode = 3
/**
* 可能是 APK 下载来源, "html5_app_bili" 表示从网页下载
*/
const val channel = "html5_app_bili"
/**
* 硬件 ID, 尚不明确生成算法
*/
@Suppress("SpellCheckingInspection")
const val hardwareId = "aBRoDWAVeRhsA3FDewMzS3lLMwM"
/**
* 屏幕尺寸, 大屏手机统一为 xxhdpi
* 此参数在新版客户端已经较少使用
*/
const val scale = "xxhdpi"
/**
* 版本号
*/
const val version = "5.44.0"
/**
* 构建版本号
*/
const val build = "5440900"
/**
* 可能是某种指纹
*/
@Suppress("SpellCheckingInspection")
const val buvid = "XXD9E43D7A1EBB6669597650E3EE417D9E7F5"
/**
* Profile
*/
const val env = "prod"
/**
* A/B test
*/
const val abTest = ""
@Suppress("SpellCheckingInspection")
const val actionKey = "appkey"
/**
* 统计用的组合参数
*/
@Suppress("SpellCheckingInspection")
const val statistics = """{"appId":$appId,"platform":$platformCode,"version":$version,"abtest":$abTest}"""
/**
* 在一些统计信息中使用
*/
const val deviceToken = "190e35f7e01c444c9d9"
const val mobileBrand = "MI"
const val mobileModel = "MI 8"
const val mobileVersion = "9.0"
const val notifySwitch = 1
const val pushSDK = 9
const val teenagersMode = 0
const val timeZone = 8
const val deviceName = "$mobileBrand$mobileModel"
const val devicePlatform = "Android$mobileVersion$deviceName"
/**
* 安装客户端时生成的指纹
*/
@Suppress("SpellCheckingInspection")
const val deviceId = "2019070720581656bd5dddabaa8e883dda67e1320dafe3494562035b27691b"
}

View File

@ -1,15 +0,0 @@
package com.hiczp.bilibili.rest.ktor
import io.ktor.util.AttributeKey
internal const val FORCE_QUERY_COMMON_PARAMS = "ForceQueryCommonParams"
internal val FORCE_QUERY_COMMON_PARAMS_ATTRIBUTE_KEY = AttributeKey<String>(FORCE_QUERY_COMMON_PARAMS)
internal const val APP_KEY = "AppKey"
internal val APP_KEY_ATTRIBUTE_KEY = AttributeKey<String>(APP_KEY)
internal const val APP_SECRET = "AppSecret"
internal val APP_SECRET_ATTRIBUTE_KEY = AttributeKey<String>(APP_SECRET)

View File

@ -1,16 +0,0 @@
package com.hiczp.bilibili.rest.ktor
import io.ktor.client.HttpClientConfig
import io.ktor.client.features.logging.LogLevel
import io.ktor.client.features.logging.Logging
import org.slf4j.LoggerFactory
fun HttpClientConfig<*>.logging(func: () -> Unit) {
install(Logging) {
level = if (LoggerFactory.getLogger(func.javaClass).isDebugEnabled) {
LogLevel.ALL
} else {
LogLevel.NONE
}
}
}

View File

@ -1,78 +0,0 @@
package com.hiczp.bilibili.rest.ktor
import com.hiczp.bilibili.rest.BilibiliClientInherent
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.features.HttpClientFeature
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.HttpRequestPipeline
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.utils.EmptyContent
import io.ktor.http.HttpMethod
import io.ktor.http.Parameters
import io.ktor.util.AttributeKey
import io.ktor.util.InternalAPI
import io.ktor.util.StringValuesBuilder
internal class ModifyRequest(private val builder: HttpRequestBuilder.() -> Unit) {
companion object Feature : HttpClientFeature<HttpRequestBuilder, ModifyRequest> {
override val key: AttributeKey<ModifyRequest> = AttributeKey("ModifyRequest")
override fun prepare(block: HttpRequestBuilder.() -> Unit): ModifyRequest =
ModifyRequest(block)
override fun install(feature: ModifyRequest, scope: HttpClient) {
scope.requestPipeline.intercept(HttpRequestPipeline.State) {
val originBody = context.body
context.apply(feature.builder)
val newBody = context.body
if (newBody !== originBody) {
proceedWith(newBody)
}
}
}
}
}
@UseExperimental(InternalAPI::class)
internal fun HttpClientConfig<*>.commonParams(
block: StringValuesBuilder.() -> Unit
) {
fun StringValuesBuilder.addCommonParams(request: HttpRequestBuilder): StringValuesBuilder {
block()
@Suppress("SpellCheckingInspection")
appendMissing("appkey", request.attributes.getOrNull(APP_KEY_ATTRIBUTE_KEY) ?: BilibiliClientInherent.appKey)
sortAndSign(request.attributes.getOrNull(APP_SECRET_ATTRIBUTE_KEY) ?: BilibiliClientInherent.appSecret)
return this
}
install(ModifyRequest) {
//add params to query
if (method == HttpMethod.Get || attributes.contains(FORCE_QUERY_COMMON_PARAMS_ATTRIBUTE_KEY)) {
url.parameters.addCommonParams(this)
} else if (method == HttpMethod.Post) { //add to body
when (val originBody = body) {
is EmptyContent -> {
body = FormDataContent(Parameters.build {
addCommonParams(this@install)
})
}
is FormDataContent -> {
body = FormDataContent(Parameters.build {
originBody.formData.forEach { key, value ->
appendAll(key, value)
}
addCommonParams(this@install)
})
}
}
}
}
}
@UseExperimental(InternalAPI::class)
internal fun StringValuesBuilder.appendMissing(name: String, value: String) {
if (!contains(name)) {
append(name, value)
}
}

View File

@ -1,25 +0,0 @@
package com.hiczp.bilibili.rest.ktor
import io.ktor.http.encodeURLParameter
import io.ktor.util.InternalAPI
import io.ktor.util.StringValuesBuilder
@UseExperimental(InternalAPI::class)
internal fun StringValuesBuilder.sortAndSign(appSecret: String) {
val sorted = entries().sortedBy { it.key }
clear()
sorted.forEach { (key, value) ->
appendAll(key, value)
}
if (!contains("sign")) {
sorted.joinToString(separator = "&") { (key, value) ->
value.joinToString(separator = "&") {
"$key=${it.encodeURLParameter()}"
}
}.let {
it + appSecret
}.md5().let {
append("sign", it)
}
}
}

View File

@ -1,7 +0,0 @@
package com.hiczp.bilibili.rest.service
interface Ok {
fun ok(): Boolean
}
inline fun <T : Ok> T.orThrow(block: (T) -> Throwable) = if (ok()) this else throw block(this)

View File

@ -1,36 +0,0 @@
package com.hiczp.bilibili.rest.service
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonElement
import com.google.gson.JsonObject
typealias Response = JsonObject
/**
* code 0 表示请求正确返回
*/
val Response.code
get() = this["code"].int
val Response.msg
get() = this["msg"]?.string
val Response.message
get() = this["message"]?.string
val Response.data: JsonElement?
get() = this["data"]
val Response.dataAsObj
get() = this["data"].obj
val Response.dataAsArray
get() = this["data"].array
val Response.dataAsString
get() = this["data"].string
val Response.timestamp
get() = this["ts"]?.long
fun Response.ok() = code == 0

View File

@ -1,24 +0,0 @@
package com.hiczp.bilibili.rest.service.app
import com.hiczp.bilibili.rest.service.Response
import com.hiczp.caeruleum.annotation.BaseUrl
import com.hiczp.caeruleum.annotation.Get
import com.hiczp.caeruleum.annotation.Query
@BaseUrl("https://app.bilibili.com")
interface AppService {
@Get("/x/v2/account/mine")
suspend fun mine(): Response
@Get("/x/v2/account/myinfo")
suspend fun myInfo(): Response
/**
* 直播观看历史
*/
@Get("/x/v2/history/liveList")
suspend fun liveHistory(
@Query("pn") pageNumber: Long = 1,
@Query("ps") pageSize: Long = 20
): Response
}

View File

@ -1,168 +0,0 @@
package com.hiczp.bilibili.rest.service.live
import com.hiczp.bilibili.rest.BilibiliClientInherent
import com.hiczp.bilibili.rest.ktor.FORCE_QUERY_COMMON_PARAMS
import com.hiczp.bilibili.rest.service.Response
import com.hiczp.caeruleum.annotation.*
/**
* 直播
*/
@BaseUrl("https://api.live.bilibili.com")
interface LiveService {
/**
* 房间基本信息
*/
@Get("/xlive/app-room/v1/index/getInfoByRoom")
suspend fun getInfoByRoom(@Query("room_id") roomId: Long): Response
/**
* 当前房间未开播时显示的其他直播推荐
*/
@Get("/xlive/app-room/v1/index/getOffLiveList")
suspend fun getOffLiveList(
@Query("area_id") areaId: Int,
@Query("room_id") roomId: Long,
@Query qn: Int = 0
): Response
/**
* 弹幕推送服务器列表
*/
@Get("/xlive/app-room/v1/index/getDanmuInfo")
suspend fun getDanmuInfo(@Query("room_id") roomId: Long): Response
/**
* 自己在房间中的身份信息, 包括头衔, 是否房管, 金瓜子数量等
*/
@Get("/xlive/app-room/v1/index/getInfoByUser")
suspend fun getInfoByUser(@Query("room_id") roomId: Long): Response
/**
* 房间中的历史弹幕(最近十条)
*/
@Get("/xlive/app-room/v1/dM/gethistory")
suspend fun getHistory(@Query("room_id") roomId: Long): Response
/**
* 头衔列表
*/
@Get("/rc/v1/Title/getTitle")
suspend fun getTitle(@Query scale: String = BilibiliClientInherent.scale): Response
/**
* 礼物列表
*/
@Get("/gift/v3/live/gift_config")
suspend fun giftConfig(
@Query("area_v2_id") areaV2Id: Int,
@Query("area_v2_parent_id") areaV2ParentId: Int,
@Query("room_id") roomId: Long
): Response
/**
* 查看当前房间的抽奖信息
*/
@Get("/xlive/lottery-interface/v1/lottery/getLotteryInfo")
suspend fun getLotteryInfo(@Query("roomid") roomId: Long): Response
/**
* 进房时发送的数据, 该操作将产生一条房间浏览记录
*/
@Post("/room/v1/Room/room_entry_action")
@FormUrlEncoded
suspend fun roomEntryAction(
@Field("room_id") roomId: Long,
@Field jumpFrom: Long = 0
): Response
/**
* 皮肤列表
*/
@Get("/room/v1/Skin/list")
suspend fun skinList(
@Query("skin_platform") skinPlatform: String = BilibiliClientInherent.platform,
@Query("skin_version") skinVersion: Int = 1
): Response
/**
* 我的直播站信息
*/
@Get("/live_user/v1/UserInfo/my_info")
suspend fun myInfo(): Response
/**
* 我的头衔列表
*/
@Get("/appUser/myTitleList")
suspend fun myTitleList(): Response
/**
* 今日是否已签到
*/
@Get("/rc/v2/Sign/getSignInfo")
suspend fun getSignInfo(): Response
/**
* 直播中心 -> 签到
*/
@Get("/rc/v1/Sign/doSign")
suspend fun doSign(): Response
/**
* 直播中心 -> 我的勋章
*/
@Get("/fans_medal/v2/HighQps/received_medals")
suspend fun receivedMedals(): Response
/**
* 直播中心 -> 我的勋章 -> 取消佩戴
*
* 取消佩戴当前正在佩戴的勋章
*/
@Get("/fans_medal/v5/live_fans_medal/cancelWearMedal")
suspend fun cancelWearMedal(): Response
/**
* 直播中心 -> 我的勋章 -> 佩戴
*/
@Attribute(FORCE_QUERY_COMMON_PARAMS)
@Post("/fans_medal/v1/fans_medal/wear_medal")
@FormUrlEncoded
suspend fun wearMedal(@Field("medal_id") medalId: Long): Response
/**
* 直播中心 -> 我的关注(上方的分类按钮)
*/
@Get("/relation/v1/App/getViewConfig")
suspend fun relationViewConfig(): Response
/**
* 直播中心 -> 我的关注(正在直播)
*
* @param sortRule 分类代码由 {@link #relationViewConfig()} 提供, 默认为( 0 3) 默认排序, 新开播, 按人气, 送礼最多
*/
@Get("/xlive/app-interface/v1/relation/liveAnchor")
suspend fun relationLiveAnchor(
@Query filterRule: Int = 0,
@Query qn: Int = 0,
@Query sortRule: Int = 0
): Response
/**
* 直播中心 -> 我的关注(暂未开播)
*
* @param page 翻页参数, 1 开始
*/
@Get("/xlive/app-interface/v1/relation/unliveAnchor")
suspend fun relationUnliveAnchor(
@Query page: Long = 1,
@Query("pagesize") pageSize: Long = 20
): Response
/**
* 直播中心 -> 我的头衔
*/
@Get("/rc/v1/UserTitle/getMyTitles")
suspend fun getMyTitles(): Response
}

View File

@ -1,23 +0,0 @@
package com.hiczp.bilibili.rest.service.passport
import com.hiczp.bilibili.rest.service.passport.model.LoginResponse
import io.ktor.http.Cookie
import io.ktor.util.date.GMTDate
import java.io.Serializable
data class Credential(
val userId: Long,
val accessToken: String,
val refreshToken: String,
val expiresIn: Long,
val cookies: List<Cookie> = emptyList()
) : Serializable
fun Credential.cookieMap() = cookies.associate { it.name to it.value }
fun LoginResponse.toCredential() = Credential(
mid, accessToken, refreshToken, expiresIn,
data.cookieInfo.cookies.map {
Cookie(name = it.name, value = it.value, httpOnly = it.httpOnly == 1, expires = GMTDate(it.expires))
}
)

View File

@ -1,10 +0,0 @@
@file:Suppress("CanBeParameter", "MemberVisibilityCanBePrivate")
package com.hiczp.bilibili.rest.service.passport
import com.hiczp.bilibili.rest.service.passport.model.LoginResponse
import com.hiczp.bilibili.rest.service.passport.model.RevokeResponse
class LoginException internal constructor(val loginResponse: LoginResponse) : IllegalStateException(loginResponse.message)
class RevokeException internal constructor(val revokeResponse: RevokeResponse) : IllegalStateException(revokeResponse.message)

View File

@ -1,145 +0,0 @@
package com.hiczp.bilibili.rest.service.passport
import com.hiczp.bilibili.rest.BilibiliClientInherent
import com.hiczp.bilibili.rest.ktor.appendMissing
import com.hiczp.bilibili.rest.ktor.commonParams
import com.hiczp.bilibili.rest.ktor.logging
import com.hiczp.bilibili.rest.service.passport.model.GetKeyResponse
import com.hiczp.bilibili.rest.service.passport.model.LoginResponse
import com.hiczp.bilibili.rest.service.passport.model.OAuth2Info
import com.hiczp.bilibili.rest.service.passport.model.RevokeResponse
import com.hiczp.caeruleum.annotation.*
import com.hiczp.caeruleum.create
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.features.UserAgent
import io.ktor.client.features.defaultRequest
import io.ktor.client.features.json.GsonSerializer
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.request.headers
import io.ktor.util.InternalAPI
import io.ktor.util.KtorExperimentalAPI
import java.time.Instant
private const val getKeyUrl = "/api/oauth2/getKey"
private const val loginUrl = "/api/v3/oauth2/login"
private const val revokeUrl = "/api/v2/oauth2/revoke"
private const val infoUrl = "/api/v3/oauth2/info"
@Suppress("SpellCheckingInspection")
@BaseUrl("https://passport.bilibili.com")
interface PassportService {
@Post(getKeyUrl)
suspend fun getKey(): GetKeyResponse
/**
* 多次错误的登陆尝试后将要求验证码
*/
@Post(loginUrl)
@FormUrlEncoded
suspend fun login(
@Field username: String, @Field password: String,
//以下为极验所需字段
@Field challenge: String? = null,
@Field("seccode") secCode: String? = null,
@Field validate: String? = null,
//统计字段
@Field("device_id") deviceId: String = BilibiliClientInherent.deviceId,
@Field("bili_local_id") bilibiliLocalId: String = deviceId,
@Field("device_name") deviceName: String = BilibiliClientInherent.deviceName,
@Field("device_platform") devicePlatform: String = BilibiliClientInherent.devicePlatform,
@Field("local_id") localId: String = BilibiliClientInherent.buvid
): LoginResponse
/**
* 除了 accessToken, 其他全部都是 cookie 的值
*/
@Post(revokeUrl)
@FormUrlEncoded
suspend fun revoke(
@Field("access_token") accessToken: String,
@Field("DedeUserID") dedeUserId: String? = null,
@Field("DedeUserID__ckMd5") ckMd5: String? = null,
@Field("SESSDATA") sessData: String? = null,
@Field("bili_jct") biliJct: String? = null,
@Field sid: String? = null
): RevokeResponse
/**
* 将所有 cookie Map 形式传入
*/
@Post(revokeUrl)
@FormUrlEncoded
suspend fun revoke(
@Field("access_token") accessToken: String,
@FieldMap cookieMap: Map<String, String> = emptyMap()
): RevokeResponse
/**
* 获取 OAuth2 信息
*/
@Get(infoUrl)
suspend fun info(
@Query("access_token") accessToken: String,
@Query("DedeUserID") dedeUserId: String? = null,
@Query("DedeUserID__ckMd5") ckMd5: String? = null,
@Query("SESSDATA") sessData: String? = null,
@Query("bili_jct") biliJct: String? = null,
@Query sid: String? = null,
//统计字段
@Query("device_id") deviceId: String = BilibiliClientInherent.deviceId,
@Query("bili_local_id") bilibiliLocalId: String = deviceId,
@Query("device_name") deviceName: String = BilibiliClientInherent.deviceName,
@Query("device_platform") devicePlatform: String = BilibiliClientInherent.devicePlatform,
@Query("local_id") localId: String = BilibiliClientInherent.buvid
): OAuth2Info
@Get(infoUrl)
suspend fun info(
@Query("access_token") accessToken: String,
@QueryMap cookieMap: Map<String, String> = emptyMap(),
//统计字段
@Query("device_id") deviceId: String = BilibiliClientInherent.deviceId,
@Query("bili_local_id") bilibiliLocalId: String = deviceId,
@Query("device_name") deviceName: String = BilibiliClientInherent.deviceName,
@Query("device_platform") devicePlatform: String = BilibiliClientInherent.devicePlatform,
@Query("local_id") localId: String = BilibiliClientInherent.buvid
): OAuth2Info
companion object : PassportService by passportService
}
@Suppress("SpellCheckingInspection")
@UseExperimental(KtorExperimentalAPI::class, InternalAPI::class)
private val passportService = HttpClient(CIO) {
install(UserAgent) {
agent = BilibiliClientInherent.userAgent
}
defaultRequest {
headers {
with(BilibiliClientInherent) {
appendMissing("Display-ID", buvid)
appendMissing("Buvid", buvid)
appendMissing("Device-ID", hardwareId)
appendMissing("env", env)
appendMissing("APP-KEY", platform)
}
}
}
commonParams {
with(BilibiliClientInherent) {
appendMissing("appkey", appKey)
appendMissing("build", build)
appendMissing("buvid", buvid)
appendMissing("channel", channel)
appendMissing("mobi_app", platform)
appendMissing("platform", platform)
appendMissing("statistics", statistics)
appendMissing("ts", Instant.now().epochSecond.toString())
}
}
install(JsonFeature) {
serializer = GsonSerializer()
}
logging { }
}.create<PassportService>()

View File

@ -1,28 +0,0 @@
package com.hiczp.bilibili.rest.service.passport.model
import com.google.gson.annotations.SerializedName
import com.hiczp.bilibili.rest.ktor.PEM
import com.hiczp.bilibili.rest.service.Ok
data class GetKeyResponse(
@SerializedName("message")
val message: String? = null,
@SerializedName("ts")
val ts: Long = 0, // 1562229380
@SerializedName("code")
val code: Int = 0, // 0
@SerializedName("data")
val `data`: Data = Data()
) : Ok {
data class Data(
@SerializedName("hash")
val hash: String = "", // a953480d976dd1ba
@SerializedName("key")
val key: PEM = "" // -----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjb4V7EidX/ym28t2ybo0U6t0n6p4ej8VjqKHg100va6jkNbNTrLQqMCQCAYtXMXXp2Fwkk6WR+12N9zknLjf+C9sx/+l48mjUU8RqahiFD1XT/u2e0m2EN029OhCgkHx3Fc/KlFSIbak93EH/XlYis0w+Xl69GV6klzgxW6d2xQIDAQAB-----END PUBLIC KEY-----
)
override fun ok() = code == 0
val hash get() = data.hash
val key get() = data.key
}

View File

@ -1,64 +0,0 @@
package com.hiczp.bilibili.rest.service.passport.model
import com.google.gson.annotations.SerializedName
import com.hiczp.bilibili.rest.service.Ok
data class LoginResponse(
@SerializedName("message")
val message: String? = null,
@SerializedName("ts")
val ts: Long = 0, // 1562229381
@SerializedName("code")
val code: Int = 0, // 0
@SerializedName("data")
val `data`: Data = Data()
) : Ok {
data class Data(
@SerializedName("url")
val url: String? = null,
@SerializedName("status")
val status: Int = 0, // 0
@SerializedName("token_info")
val tokenInfo: TokenInfo = TokenInfo(),
@SerializedName("cookie_info")
val cookieInfo: CookieInfo = CookieInfo(),
@SerializedName("sso")
val sso: List<String> = listOf()
) {
data class CookieInfo(
@SerializedName("cookies")
val cookies: List<Cookie> = listOf(),
@SerializedName("domains")
val domains: List<String> = listOf()
) {
data class Cookie(
@SerializedName("name")
val name: String = "", // SESSDATA
@SerializedName("value")
val value: String = "", // 824135b9%2C1564821381%2C7aa53171
@SerializedName("http_only")
val httpOnly: Int = 0, // 1
@SerializedName("expires")
val expires: Long = 0 // 1564821381
)
}
data class TokenInfo(
@SerializedName("mid")
val mid: Long = 0, // 20293030
@SerializedName("access_token")
val accessToken: String = "", // 4c50c1a6f82c76f32beebe646c1cc771
@SerializedName("refresh_token")
val refreshToken: String = "", // c8bbbaa1ba576d672fffb36f03b11971
@SerializedName("expires_in")
val expiresIn: Long = 0 // 2592000
)
}
override fun ok() = code == 0
val mid get() = data.tokenInfo.mid
val accessToken get() = data.tokenInfo.accessToken
val refreshToken get() = data.tokenInfo.refreshToken
val expiresIn get() = data.tokenInfo.expiresIn
}

View File

@ -1,30 +0,0 @@
package com.hiczp.bilibili.rest.service.passport.model
import com.google.gson.annotations.SerializedName
import com.hiczp.bilibili.rest.service.Ok
data class OAuth2Info(
@SerializedName("message")
val message: String? = null,
@SerializedName("ts")
val ts: Long = 0, // 1562505063
@SerializedName("code")
val code: Int = 0, // 0
@SerializedName("data")
val `data`: Data = Data()
) : Ok {
data class Data(
@SerializedName("mid")
val mid: Long = 0, // 20293030
@SerializedName("access_token")
val accessToken: String = "", // eb371c8f089f33da2fc65e5607269d71
@SerializedName("expires_in")
val expiresIn: Long = 0 // 2591950
)
override fun ok() = code == 0
val mid get() = data.mid
val accessToken get() = data.accessToken
val expiresIn get() = data.expiresIn
}

View File

@ -1,15 +0,0 @@
package com.hiczp.bilibili.rest.service.passport.model
import com.google.gson.annotations.SerializedName
import com.hiczp.bilibili.rest.service.Ok
data class RevokeResponse(
@SerializedName("message")
val message: String? = "", // user not login
@SerializedName("ts")
val ts: Long = 0, // 1562653921
@SerializedName("code")
val code: Int = 0 // -101
) : Ok {
override fun ok() = code == 0
}

View File

@ -1,9 +0,0 @@
package com.hiczp.bilibili.rest.utils
import kotlin.reflect.KProperty0
import kotlin.reflect.jvm.isAccessible
fun KProperty0<*>.isLazyInitialized(): Boolean {
isAccessible = true
return (getDelegate() as Lazy<*>).isInitialized()
}

View File

@ -1,58 +0,0 @@
package com.hiczp.bilibili.rest
import com.github.salomonbrys.kotson.byString
import com.github.salomonbrys.kotson.fromJson
import com.github.salomonbrys.kotson.get
import com.google.gson.Gson
import com.google.gson.JsonParser
import com.hiczp.bilibili.rest.service.passport.model.LoginResponse
import com.hiczp.bilibili.rest.service.passport.toCredential
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.*
import org.junit.jupiter.api.TestInstance.Lifecycle
import java.io.FileNotFoundException
@TestInstance(Lifecycle.PER_CLASS)
class BilibiliClientTest {
private lateinit var loggedInBilibiliClient: BilibiliClient
private val bilibiliClient = BilibiliClient()
@BeforeAll
fun init() {
val json = BilibiliClientTest::class.java.getResourceAsStream("/config.json")?.let {
JsonParser().parse(it.reader())
} ?: throw FileNotFoundException("Rename '_config.json' to 'config.json' before Test")
loggedInBilibiliClient = BilibiliClient(
Gson().fromJson<LoginResponse>(json["loginResponse"]).toCredential()
)
}
@Disabled
@Test
fun login() {
val json = BilibiliClientTest::class.java.getResourceAsStream("/config.json")?.let {
JsonParser().parse(it.reader())
} ?: throw FileNotFoundException("Rename '_config.json' to 'config.json' before Test")
val username by json.byString
val password by json.byString
val bilibiliClient = BilibiliClient()
runBlocking {
bilibiliClient.login(username, password)
bilibiliClient.credential.value!!.println()
bilibiliClient.revoke()!!.println()
}
}
@Test
fun getOAuth2Info() {
runBlocking {
loggedInBilibiliClient.oauth2Info()!!.println()
}
}
@AfterAll
fun dispose() {
loggedInBilibiliClient.close()
bilibiliClient.close()
}
}

View File

@ -1,3 +0,0 @@
package com.hiczp.bilibili.rest
internal fun Any.println() = println(this)

View File

@ -1,63 +0,0 @@
{
"username": "123456789",
"password": "123456",
"loginResponse": {
"ts": 1550629285,
"code": 0,
"data": {
"status": 0,
"token_info": {
"mid": 20293030,
"access_token": "b0b214e97b7bf388769eb727da27f951",
"refresh_token": "54c56bda9bde64cacb44a14e92007d51",
"expires_in": 2592000
},
"cookie_info": {
"cookies": [
{
"name": "bili_jct",
"value": "578b271b308c760cbd281c37afc420c4",
"http_only": 0,
"expires": 1553221285
},
{
"name": "DedeUserID",
"value": "20293035",
"http_only": 0,
"expires": 1553221285
},
{
"name": "DedeUserID__ckMd5",
"value": "cdff5c8e58b793cd",
"http_only": 0,
"expires": 1553221285
},
{
"name": "sid",
"value": "ja724hee",
"http_only": 0,
"expires": 1553221285
},
{
"name": "SESSDATA",
"value": "eaa5b420%2C1553221285%2C59590a31",
"http_only": 1,
"expires": 1553221285
}
],
"domains": [
".bilibili.com",
".biligame.com",
".im9.com",
".bigfunapp.cn"
]
},
"sso": [
"https://passport.bilibili.com/api/v2/sso",
"https://passport.biligame.com/api/v2/sso",
"https://passport.im9.com/api/v2/sso",
"https://passport.bigfunapp.cn/api/v2/sso"
]
}
}
}

View File

@ -1 +0,0 @@
org.slf4j.simpleLogger.defaultLogLevel=trace

View File

@ -1,4 +1 @@
rootProject.name = 'bilibili-api'
include 'rest'
include 'websocket'

View File

@ -0,0 +1,29 @@
package com.hiczp.bilibili.api
import com.hiczp.bilibili.api.utils.logging
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.features.*
import io.ktor.client.features.json.*
import java.time.Instant
class BilibiliAPI(
httpClientEngine: HttpClientEngine,
bilibiliClientProperties: BilibiliClientProperties = BilibiliClientProperties.DEFAULT
) {
val initTimestamp = Instant.now().epochSecond
val passport = HttpClient(httpClientEngine) {
install(UserAgent) {
agent = bilibiliClientProperties.userAgent
}
install(JsonFeature) {
serializer = GsonSerializer()
}
logging { }
defaultRequest {
println()
}
}
}

View File

@ -0,0 +1,121 @@
package com.hiczp.bilibili.api
/**
* 客户端固有属性
* 5.54.0
*/
@Suppress("MemberVisibilityCanBePrivate", "SpellCheckingInspection")
class BilibiliClientProperties {
/**
* 版本号
*/
var version = "5.54.0"
/**
* 构建版本号
*/
var build = "5540500"
/**
* 默认 UA, 用于大多数访问
*/
var userAgent = "Mozilla/5.0 BiliDroid/$version (bbcallen@gmail.com)"
/**
* Android 平台的 appKey(该默认值为普通版客户端, 非概念版)
*/
var appKey = "1d8b6e7d45233436"
/**
* 由反编译 so 文件得到的 appSecret, appKey 必须匹配
*/
var appSecret = "560c52ccd288fed045859ed18bffd973"
/**
* 获取视频播放地址使用的 appKey, 与访问其他 RestFulAPI 所用的 appKey 不一致
*/
var videoAppKey = "iVGUTjsxvpLeuDCf"
/**
* 获取视频播放地址所用的 appSecret
*/
var videoAppSecret = "aHRmhWMLkdeMuILqORnYZocwMBpMEOdt"
/**
* 可能指普通版客户端
*/
var appId = 1
/**
* 客户端平台
*/
var platform = "android"
/**
* 客户端平台代码
*/
var platformCode = 3
/**
* 可能是 APK 下载来源
*/
var channel = "bilih5"
/**
* 硬件 ID, 尚不明确生成算法
*/
var hardwareId = "bxNvCmcSfh9rBHZFdQ09RXUNPQ"
/**
* 屏幕尺寸, 大屏手机统一为 xxhdpi
* 此参数在新版客户端已经较少使用
*/
var scale = "xxhdpi"
/**
* 可能是某种哈希
*/
var buvid = "XXF2498F337ECA0882990F812CC49EC370244"
/**
* Profile
*/
var env = "prod"
/**
* A/B test
*/
var abTest = ""
var actionKey = "appkey"
/**
* 统计用的组合参数
*/
var statistics = """{"appId":$appId,"platform":$platformCode,"version":"$version","abtest":"$abTest"}"""
/**
* 在一些统计信息中使用
*/
var deviceToken = "190e35f7e01c444c9d9"
var mobileBrand = "MI"
var mobileModel = "MI 8"
var mobileVersion = "9.0"
var notifySwitch = 1
var pushSDK = 9
var teenagersMode = 0
var timeZone = 8
var device = "phone"
var deviceName = "$mobileBrand$mobileModel"
var devicePlatform = "Android$mobileVersion$deviceName"
/**
* 安装客户端时生成的指纹
*/
var deviceId = "1f11ac09664ee6a7426df602439a7fe420200312163841f3f232301c061f7df6"
companion object {
val DEFAULT = BilibiliClientProperties()
}
}

View File

@ -0,0 +1,86 @@
package com.hiczp.bilibili.api.passport
import com.hiczp.bilibili.api.BilibiliClientProperties
import com.hiczp.bilibili.api.passport.model.GetKeyResponse
import com.hiczp.bilibili.api.passport.model.LoginResponse
import com.hiczp.bilibili.api.passport.model.OAuth2Info
import com.hiczp.bilibili.api.passport.model.RevokeResponse
import com.hiczp.caeruleum.annotation.*
/**
* Bilibili OAuth2 V3 API
*/
@Suppress("SpellCheckingInspection")
@BaseUrl("https://passport.bilibili.com")
interface PassportAPI {
/**
* 获得 RSA 公钥
* 固定参数 appkey, build, channel, mobi_app, platform, statistics, ts, sign
*/
@Post("/api/oauth2/getKey")
suspend fun getKey(): GetKeyResponse
/**
* 登陆
* 多次错误的登陆尝试后将要求验证码
*/
@Post("/api/v3/oauth2/login")
@FormUrlEncoded
suspend fun login(
@Field username: String, @Field password: String,
//极验所需字段
@Field challenge: String? = null,
@Field("seccode") secCode: String? = null,
@Field validate: String? = null,
//统计字段
@Field("buvid") buvid: String = BilibiliClientProperties.DEFAULT.buvid,
@Field("device") device: String = BilibiliClientProperties.DEFAULT.device,
@Field("device_id") deviceId: String = BilibiliClientProperties.DEFAULT.deviceId,
@Field("bili_local_id") bilibiliLocalId: String = deviceId,
@Field("device_name") deviceName: String = BilibiliClientProperties.DEFAULT.deviceName,
@Field("device_platform") devicePlatform: String = BilibiliClientProperties.DEFAULT.devicePlatform,
@Field("local_id") localId: String = buvid
): LoginResponse
/**
* 获取用户 ID
*/
@Get("/api/v3/oauth2/info")
suspend fun info(
@Query("access_token") accessToken: String,
@QueryMap cookieMap: Map<String, String> = emptyMap(),
//统计字段
@Field("buvid") buvid: String = BilibiliClientProperties.DEFAULT.buvid,
@Field("device") device: String = BilibiliClientProperties.DEFAULT.device,
@Field("device_id") deviceId: String = BilibiliClientProperties.DEFAULT.deviceId,
@Field("bili_local_id") bilibiliLocalId: String = deviceId,
@Field("device_name") deviceName: String = BilibiliClientProperties.DEFAULT.deviceName,
@Field("device_platform") devicePlatform: String = BilibiliClientProperties.DEFAULT.devicePlatform,
@Field("local_id") localId: String = buvid
): OAuth2Info
/**
* 登出
*/
@Post("/x/passport-login/revoke")
@FormUrlEncoded
suspend fun revoke(
@Field("access_token") accessToken: String,
/**
* 用户 ID
*/
@Field mid: Long,
/**
* 名为 SESSDATA cookie 的值
*/
@Field session: String,
//统计字段
@Field("buvid") buvid: String = BilibiliClientProperties.DEFAULT.buvid,
@Field("device") device: String = BilibiliClientProperties.DEFAULT.device,
@Field("device_id") deviceId: String = BilibiliClientProperties.DEFAULT.deviceId,
@Field("bili_local_id") bilibiliLocalId: String = deviceId,
@Field("device_name") deviceName: String = BilibiliClientProperties.DEFAULT.deviceName,
@Field("device_platform") devicePlatform: String = BilibiliClientProperties.DEFAULT.devicePlatform,
@Field("local_id") localId: String = buvid
): RevokeResponse
}

View File

@ -0,0 +1,26 @@
package com.hiczp.bilibili.api.passport.model
import com.hiczp.bilibili.api.utils.PEM
//{
// "ts": 1584208643,
// "code": 0,
// "data": {
// "hash": "2e5f153071bede36",
// "key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjb4V7EidX/ym28t2ybo0U6t0n\n6p4ej8VjqKHg100va6jkNbNTrLQqMCQCAYtXMXXp2Fwkk6WR+12N9zknLjf+C9sx\n/+l48mjUU8RqahiFD1XT/u2e0m2EN029OhCgkHx3Fc/KlFSIbak93EH/XlYis0w+\nXl69GV6klzgxW6d2xQIDAQAB\n-----END PUBLIC KEY-----\n"
// }
//}
data class GetKeyResponse(
val message: String?,
val ts: Long, // 1562229380
val code: Int, // 0
val `data`: Data
) {
data class Data(
val hash: String, // a953480d976dd1ba
val key: PEM // -----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDjb4V7EidX/ym28t2ybo0U6t0n6p4ej8VjqKHg100va6jkNbNTrLQqMCQCAYtXMXXp2Fwkk6WR+12N9zknLjf+C9sx/+l48mjUU8RqahiFD1XT/u2e0m2EN029OhCgkHx3Fc/KlFSIbak93EH/XlYis0w+Xl69GV6klzgxW6d2xQIDAQAB-----END PUBLIC KEY-----
)
val hash get() = data.hash
val key get() = data.key
}

View File

@ -0,0 +1,106 @@
package com.hiczp.bilibili.api.passport.model
import com.google.gson.annotations.SerializedName
//{
// "ts": 1584208643,
// "code": 0,
// "data": {
// "status": 0,
// "token_info": {
// "mid": 20293030,
// "access_token": "4dcbc20bf766938a3501a64e61e8ca31",
// "refresh_token": "e61048dbc3f9added8527ac025352731",
// "expires_in": 2592000
// },
// "cookie_info": {
// "cookies": [{
// "name": "bili_jct",
// "value": "43afa5cec21c54207185d44795dc7d09",
// "http_only": 0,
// "expires": 1586800643
// }, {
// "name": "DedeUserID",
// "value": "20293030",
// "http_only": 0,
// "expires": 1586800643
// }, {
// "name": "DedeUserID__ckMd5",
// "value": "cdff5c8e58b793cc",
// "http_only": 0,
// "expires": 1586800643
// }, {
// "name": "sid",
// "value": "ai99lik7",
// "http_only": 0,
// "expires": 1586800643
// }, {
// "name": "SESSDATA",
// "value": "7a6de960%2C1586800643%2Ce06aee31",
// "http_only": 1,
// "expires": 1586800643
// }],
// "domains": [".bilibili.com", ".biligame.com", ".im9.com", ".bigfunapp.cn"]
// },
// "sso": ["https://passport.bilibili.com/api/v2/sso", "https://passport.biligame.com/api/v2/sso", "https://passport.im9.com/api/v2/sso", "https://passport.bigfunapp.cn/api/v2/sso"]
// }
//}
data class LoginResponse(
val message: String?, // "验证码错误"
val ts: Long, // 1562229381
val code: Int, // 0
/**
* 密码错误时, 可能没有 data 字段
*/
val `data`: Data
) {
/**
* 如果登陆失败, 例如需要验证手机号, 需要极验等, 此时会有 url 字段并且其他字段可能不存在(包括 status 也可能不存在)
*/
data class Data(
val url: String?,
val status: Int, // 0
@SerializedName("token_info")
val tokenInfo: TokenInfo,
@SerializedName("cookie_info")
val cookieInfo: CookieInfo,
val sso: List<String>
) {
data class CookieInfo(
val cookies: List<Cookie>,
val domains: List<String>
) {
data class Cookie(
val name: String, // SESSDATA
val value: String, // 824135b9%2C1564821381%2C7aa53171
@SerializedName("http_only")
val httpOnly: Int, // 1
/**
* 时间戳
*/
val expires: Long // 1564821381
)
}
data class TokenInfo(
/**
* 用户 ID
*/
val mid: Long, // 20293030
@SerializedName("access_token")
val accessToken: String, // 4c50c1a6f82c76f32beebe646c1cc771
@SerializedName("refresh_token")
val refreshToken: String, // c8bbbaa1ba576d672fffb36f03b11971
/**
* 单位为秒, 每个 token 的有效期为一个月
*/
@SerializedName("expires_in")
val expiresIn: Long // 2592000
)
}
val mid get() = data.tokenInfo.mid
val accessToken get() = data.tokenInfo.accessToken
val refreshToken get() = data.tokenInfo.refreshToken
val expiresIn get() = data.tokenInfo.expiresIn
}

View File

@ -0,0 +1,31 @@
package com.hiczp.bilibili.api.passport.model
import com.google.gson.annotations.SerializedName
//{
// "ts": 1584209074,
// "code": 0,
// "data": {
// "mid": 20293030,
// "access_token": "53802794ed685411e30b320614261031",
// "expires_in": 2591992
// }
//}
data class OAuth2Info(
val message: String?,
val ts: Long, // 1562505063
val code: Int, // 0
val `data`: Data
) {
data class Data(
val mid: Long, // 20293030
@SerializedName("access_token")
val accessToken: String, // eb371c8f089f33da2fc65e5607269d71
@SerializedName("expires_in")
val expiresIn: Long // 2591950
)
val mid get() = data.mid
val accessToken get() = data.accessToken
val expiresIn get() = data.expiresIn
}

View File

@ -0,0 +1,13 @@
package com.hiczp.bilibili.api.passport.model
//{
// "code": 0,
// "message": "0",
// "ttl": 1
//}
data class RevokeResponse(
val message: String?, // user not login
val ts: Long?, // 1562653921
val code: Int, // -101
val ttl: Int? // 1
)

View File

@ -0,0 +1,7 @@
package com.hiczp.bilibili.api.utils
import io.ktor.util.*
internal const val FORCE_QUERY_COMMON_PARAMS = "ForceQueryCommonParams"
internal val FORCE_QUERY_COMMON_PARAMS_ATTRIBUTE_KEY = AttributeKey<String>(FORCE_QUERY_COMMON_PARAMS)

View File

@ -1,4 +1,4 @@
package com.hiczp.bilibili.rest.ktor
package com.hiczp.bilibili.api.utils
import java.security.KeyFactory
import java.security.MessageDigest
@ -9,8 +9,7 @@ import javax.crypto.Cipher
@Suppress("SpellCheckingInspection")
private val hexTable = "0123456789abcdef".toCharArray()
//optimized md5
internal fun String.md5() = buildString(32) {
internal fun String.toMD5() = buildString(32) {
MessageDigest.getInstance("MD5").digest(toByteArray()).forEach {
val value = it.toInt() and 0xFF
append(hexTable[value ushr 4])
@ -18,21 +17,21 @@ internal fun String.md5() = buildString(32) {
}
}
internal fun String.base64() = Base64.getDecoder().decode(this)
internal fun String.base64Decode() = Base64.getDecoder().decode(this)
internal fun ByteArray.base64() = Base64.getEncoder().encodeToString(this)
internal fun String.rsaEncrypt(publicKey: DER) =
X509EncodedKeySpec(publicKey).let {
KeyFactory.getInstance("RSA").generatePublic(it)
}.let {
Cipher.getInstance("RSA/ECB/PKCS1Padding").apply {
init(Cipher.ENCRYPT_MODE, it)
}
}.doFinal(toByteArray()).base64()
internal fun ByteArray.base64Encode() = Base64.getEncoder().encodeToString(this)
internal typealias PEM = String
internal typealias DER = ByteArray
internal fun PEM.toDER(): DER = split('\n').filterNot { it.startsWith('-') }.joinToString(separator = "").base64()
internal fun String.rsaEncrypt(publicKey: DER) =
X509EncodedKeySpec(publicKey).let {
KeyFactory.getInstance("RSA").generatePublic(it)
}.let {
Cipher.getInstance("RSA/ECB/PKCS1Padding").apply {
init(Cipher.ENCRYPT_MODE, it)
}
}.doFinal(toByteArray()).base64Encode()
internal fun PEM.toDER(): DER = split('\n').filterNot { it.startsWith('-') }.joinToString(separator = "").base64Decode()

View File

@ -0,0 +1,15 @@
package com.hiczp.bilibili.api.utils
import io.ktor.client.*
import io.ktor.client.features.logging.*
import org.slf4j.LoggerFactory
internal fun HttpClientConfig<*>.logging(func: () -> Unit) {
install(Logging) {
level = if (LoggerFactory.getLogger(func.javaClass).isDebugEnabled) {
LogLevel.ALL
} else {
LogLevel.NONE
}
}
}

View File

@ -0,0 +1,27 @@
package com.hiczp.bilibili.api.utils
import io.ktor.http.*
import io.ktor.util.*
@OptIn(InternalAPI::class)
internal fun StringValuesBuilder.appendMissing(name: String, value: String) = appendMissing(name, listOf(value))
private const val SIGN = "sign"
@OptIn(InternalAPI::class)
internal fun StringValuesBuilder.sortAndSign(appSecret: String) {
val sorted = entries().asSequence().filter { it.key != SIGN }.sortedBy { it.key }
clear()
sorted.forEach { (key, value) ->
appendAll(key, value)
}
sorted.joinToString(separator = "&") { (key, value) ->
value.joinToString(separator = "&") {
"$key=${it.encodeURLParameter()}"
}
}.let {
it + appSecret
}.toMD5().let {
append(SIGN, it)
}
}

View File

@ -1,16 +0,0 @@
//ktor
dependencies {
// https://mvnrepository.com/artifact/io.ktor/ktor-client-websocket
//api group: 'io.ktor', name: 'ktor-client-websocket', version: ktor_version
api group: 'io.ktor', name: 'ktor-client-websocket', version: '1.2.0-alpha-2'
// https://mvnrepository.com/artifact/io.ktor/ktor-client-cio
api group: 'io.ktor', name: 'ktor-client-cio', version: ktor_version
}
//json
dependencies {
// https://mvnrepository.com/artifact/com.google.code.gson/gson
api group: 'com.google.code.gson', name: 'gson', version: '2.8.5'
// https://mvnrepository.com/artifact/com.github.salomonbrys.kotson/kotson
api group: 'com.github.salomonbrys.kotson', name: 'kotson', version: '2.5.0'
}

View File

@ -1,99 +0,0 @@
package com.hiczp.bilibili.websocket
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.features.websocket.DefaultClientWebSocketSession
import io.ktor.client.features.websocket.WebSockets
import io.ktor.client.features.websocket.wss
import io.ktor.util.InternalAPI
import io.ktor.util.KtorExperimentalAPI
import io.ktor.util.error
import io.ktor.util.moveToByteArray
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.channels.produce
import mu.KotlinLogging
private val logger = KotlinLogging.logger { }
typealias BilibiliClientWebSocketSession = DefaultClientWebSocketSession
@UseExperimental(ObsoleteCoroutinesApi::class, InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class, InternalAPI::class)
val BilibiliClientWebSocketSession.resolvedPackets
get() = produce {
incoming.consumeEach { frame ->
frame.toPackets().forEach { packet ->
when (packet.packetType) {
PacketType.COMMAND -> send(CommandPacket(packet.content))
PacketType.POPULARITY -> send(PopularityPacket(packet.content))
else -> logger.error {
"""
Unknown packet: $packet
Content: ${packet.content.moveToByteArray().joinToString(separator = " ") { "%02x".format(it) }}
""".trimIndent()
}
}
}
}
}
/**
* 直播客户端
*
* @param roomId 房间号, 如果使用短号可能得不到正确的人气值信息
* @param anchorUserId 房间主播的 ID
* @param host 服务器地址
* @param port 端口
*/
@Suppress("MemberVisibilityCanBePrivate")
class LiveClient(
val roomId: Long,
val anchorUserId: Long = 0,
@Suppress("SpellCheckingInspection")
val host: String = "broadcastlv.chat.bilibili.com",
val port: Int = 443,
private val block: suspend BilibiliClientWebSocketSession.(LiveClient) -> Unit
) {
@UseExperimental(InternalAPI::class, KtorExperimentalAPI::class, ExperimentalCoroutinesApi::class)
suspend fun connect() = httpClient.wss(host = host, port = port, path = "/sub") {
send(PresetPacket.enterRoom(anchorUserId, roomId))
//impossible
if (incoming.receive().toPackets().first().packetType != PacketType.ENTER_ROOM_RESPONSE) {
error("Unexpected packet type")
}
logger.debug { "Connected to room $roomId" }
//心跳包, 30s 一次
val heartBeatJob = launch(coroutineContext) {
try {
while (!outgoing.isClosedForSend) {
send(PresetPacket.heartbeat())
logger.trace { "Send heartbeat in room $roomId" }
delay(30_000)
}
} catch (ignore: CancellationException) {
//ignore
} catch (e: Exception) {
logger.error(e)
} finally {
logger.trace { "Stop heartbeat sending for room $roomId" }
}
}
try {
block(this@LiveClient)
} finally {
heartBeatJob.cancel()
logger.debug { "Connection closed from room $roomId" }
}
}
companion object {
@UseExperimental(KtorExperimentalAPI::class)
private val httpClient by lazy {
HttpClient(CIO).config {
install(WebSockets)
}
}
}
}

View File

@ -1,103 +0,0 @@
package com.hiczp.bilibili.websocket
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.WebSocketSession
import io.ktor.util.InternalAPI
import io.ktor.util.decodeString
import java.nio.ByteBuffer
/**
* 数据包模型
* 由于 Android APP 并未全线换成 wss, 以下用的是移动版网页的协议
* 数据包头部结构 00 00 00 65 00 10 00 01 00 00 00 07 00 00 00 01
* |数据包总长度| |头长| |tag| |数据包类型 | | tag |
*
* @param shortTag 一种 tag, 如果是非 command 数据包则为 1, 否则为 0
* @param packetType 数据包类型
* @param tag tagShort, 但是为 int 类型
* @param content 正文内容
*/
@Suppress("MemberVisibilityCanBePrivate")
internal data class Packet(
val shortTag: Short = 1,
val packetType: PacketType,
val tag: Int = shortTag.toInt(),
val content: ByteBuffer
) {
val totalLength
get() = headerLength + content.limit()
fun toFrame() = Frame.Binary(
true,
ByteBuffer.allocate(totalLength)
.putInt(totalLength)
.putShort(headerLength)
.putShort(shortTag)
.putInt(packetType.value)
.putInt(tag)
.put(content).apply {
flip()
}!!
)
companion object {
const val headerLength: Short = 0x10
}
}
sealed class ResolvedPacket<T> {
abstract val packetType: PacketType
abstract val content: T
}
data class PopularityPacket(override val content: Int) : ResolvedPacket<Int>() {
constructor(content: ByteBuffer) : this(content.int)
override val packetType = PacketType.POPULARITY
}
data class CommandPacket(override val content: Command) : ResolvedPacket<Command>() {
@UseExperimental(InternalAPI::class)
constructor(content: ByteBuffer) : this(jsonParser.parse(content.decodeString()).obj)
override val packetType = PacketType.COMMAND
inline val cmd
get() = content.cmd
companion object {
private val jsonParser = JsonParser()
}
}
/**
* 一个 Message 中可能包含多个数据包
*/
internal fun Frame.toPackets(): Sequence<Packet> {
val bufferLength = buffer.limit()
return sequence {
while (buffer.hasRemaining()) {
val startPosition = buffer.position()
val totalLength = buffer.int
buffer.position(buffer.position() + 2) //skip headerLength
val shortTag = buffer.short
val packetType = PacketType.getByValue(buffer.int)
val tag = buffer.int
buffer.limit(startPosition + totalLength)
val content = buffer.slice()
buffer.position(buffer.limit())
buffer.limit(bufferLength)
yield(Packet(shortTag, packetType, tag, content))
}
}
}
internal suspend inline fun WebSocketSession.send(packet: Packet) = send(packet.toFrame())
typealias Command = JsonObject
val Command.cmd
get() = this["cmd"].asString!!

View File

@ -1,22 +0,0 @@
package com.hiczp.bilibili.websocket
enum class PacketType(val value: Int) {
//impossible
UNKNOWN(0),
HEARTBEAT(2),
POPULARITY(3),
COMMAND(5),
ENTER_ROOM(7),
ENTER_ROOM_RESPONSE(8);
companion object {
private val byValueMap = values().associateBy { it.value }
fun getByValue(value: Int) = byValueMap[value] ?: UNKNOWN
}
}

View File

@ -1,37 +0,0 @@
package com.hiczp.bilibili.websocket
import com.github.salomonbrys.kotson.jsonObject
import java.nio.ByteBuffer
/**
* 预设数据包
*/
internal object PresetPacket {
/**
* 进房数据包
* {"uid":50333369,"roomid":14073662,"protover":1}
*
* @param anchorUserId 房间主的用户 ID
* @param roomId 房间号
*/
@Suppress("SpellCheckingInspection")
fun enterRoom(anchorUserId: Long, roomId: Long) = Packet(
packetType = PacketType.ENTER_ROOM,
content = ByteBuffer.wrap(
jsonObject(
"uid" to anchorUserId,
"roomid" to roomId,
"protover" to 1 //该值总为 1
).toString().toByteArray()
)
)
/**
* 心跳包
* 心跳包的正文内容可能是故意的, 为固定值 "[object Object]"
*/
fun heartbeat(content: ByteBuffer = ByteBuffer.wrap("[object Object]".toByteArray())) = Packet(
packetType = PacketType.HEARTBEAT,
content = content
)
}

View File

@ -1,136 +0,0 @@
package com.hiczp.bilibili.websocket.model
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonArray
import com.hiczp.bilibili.websocket.Command
import com.hiczp.bilibili.websocket.cmd
@Suppress("MemberVisibilityCanBePrivate")
inline class DanmakuMessage(val data: Command) {
val info: JsonArray get() = data.get("info").array
val basicInfo get() = info[0].array
/**
* 弹幕池
*/
val pool get() = basicInfo[0].int
/**
* 弹幕模式, 可能和视频弹幕一致
* (1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)
*/
val mode get() = basicInfo[1].int
/**
* 弹幕字号
*/
val fontSize get() = basicInfo[2].int
/**
* 弹幕颜色
*/
val color get() = basicInfo[3].int
/**
* 弹幕发送时间
*/
val timestamp get() = basicInfo[4].long
/**
* 发送此弹幕的客户端进入直播间的时间.
* 注意, 如果弹幕来自一个 Android 客户端, 那么此字段是一个随机数(不包括符号位有9位或者10位), 可能为负数
*/
val enterRoomTime get() = basicInfo[5].long
/**
* 用户 ID CRC32 校验和
* 注意, 不需要用此字段来得到用户 ID
*/
val userIdCrc32 get() = basicInfo[7].string
/**
* 弹幕的内容
*/
val message get() = info[1].string
val userInfo get() = info[2].array
val userId get() = userInfo[0].long
val nickname get() = userInfo[1].string
val isAdmin get() = userInfo[2].int
val isVip get() = userInfo[3].int
val isSVip get() = userInfo[4].int
/**
* 粉丝勋章信息
* 注意, 如果弹幕发送者没有佩戴勋章则该字段为一个空 JsonArray
* 未佩戴粉丝勋章时, 下面几个字段都会返回 null
*/
val fansMedalInfo get() = info[3].array
val fansMedalLevel get() = fansMedalInfo[0]?.int
val fansMedalName get() = fansMedalInfo[1]?.string
/**
* 粉丝勋章对应的主播的用户名
*/
val fansMedalAnchorNickname get() = fansMedalInfo[2]?.string
/**
* 粉丝勋章对应的主播的直播间号码
*/
val fansMedalAnchorRoomId get() = fansMedalInfo[3]?.long
/**
* 粉丝勋章的背景颜色
*/
val fansMedalBackgroundColor get() = fansMedalInfo[4]?.int
val userLevelInfo get() = info[4].array
/**
* UL, 发送者的用户等级, 非主播等级
*/
val userLevel get() = userLevelInfo[0].int
/**
* 用户等级标识的边框的颜色, 通常为最后一个佩戴的粉丝勋章的颜色
*/
val userLevelBorderColor get() = userLevelInfo[2].int
/**
* 用户排名, 可能为数字, 也可能是 ">50000"
*/
val userRank get() = userLevelInfo[3].string
/**
* 用户头衔
* 可能为空列表, 也可能是值为 String 类型的列表
* 可能有两项, 两项的值可能一样
*/
val userTitles get() = info[5].array.map { it.string }
/**
* 校验信息
* {
* "ts": 1553368447,
* "ct": "98688F2F"
* }
*/
val checkInfo get() = info[9].obj
}
private const val danmakuMessage = "DANMU_MSG"
fun Command.asDanmakuMessage() =
if (cmd == danmakuMessage) {
DanmakuMessage(this)
} else {
throw IllegalStateException("Cannot convert $cmd to $danmakuMessage")
}

View File

@ -1,92 +0,0 @@
package com.hiczp.bilibili.websocket
import com.hiczp.bilibili.websocket.model.asDanmakuMessage
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
@UseExperimental(ObsoleteCoroutinesApi::class)
class LiveClientTest {
@Test
fun connect() {
val liveClient = LiveClient(roomId = 23058, anchorUserId = 11153765) {
resolvedPackets.consumeEach {
when (it) {
is CommandPacket -> {
val command = it.content
println("[${command.cmd}] ${if (command.cmd == "DANMU_MSG") {
with(command.asDanmakuMessage()) {
"$nickname: $message"
}
} else {
command.toString()
}}")
}
is PopularityPacket -> {
println("Popularity: ${it.content}")
}
}
}
}
runBlocking {
liveClient.connect()
}
}
@Test
fun close() {
val liveClient = LiveClient(roomId = 23058) {
println("Connected")
resolvedPackets.consumeEach {
//process
}
}
runBlocking {
val job = launch { liveClient.connect() }
delay(5_000)
println("Prepare to close it")
job.cancel()
delay(5_000)
}
}
@Test
fun multi() {
val block: suspend BilibiliClientWebSocketSession.(LiveClient) -> Unit = { liveClient ->
println("Connected to room ${liveClient.roomId}")
resolvedPackets.consumeEach {
print("Room ${liveClient.roomId}: ")
when (it) {
is CommandPacket -> {
with(it.content) {
println("[$cmd] $this")
}
}
is PopularityPacket -> {
println("[Popularity] ${it.content}")
}
}
}
}
val liveClients = listOf(
LiveClient(roomId = 23058, block = block),
LiveClient(roomId = 5279, block = block)
)
runBlocking {
liveClients.forEach {
launch {
while (true) {
it.connect()
println("Reconnect after 1 second")
delay(1_000)
}
}
}
}
}
}

View File

@ -1 +0,0 @@
org.slf4j.simpleLogger.defaultLogLevel=trace