mirror of
https://github.com/czp3009/bilibili-api.git
synced 2025-03-21 21:00:26 +08:00
stash
This commit is contained in:
parent
b4e482e78f
commit
b17cbe6f03
101
README.md
101
README.md
@ -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
|
||||
|
267
build.gradle
267
build.gradle
@ -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 }
|
||||
}
|
||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"cmd": "ROOM_BLOCK_MSG",
|
||||
"uid": 8305711,
|
||||
"uname": "RMT0v0",
|
||||
"data": {
|
||||
"uid": 8305711,
|
||||
"uname": "RMT0v0",
|
||||
"operator": 1
|
||||
},
|
||||
"roomid": 1029
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"cmd": "ROOM_REAL_TIME_MESSAGE_UPDATE",
|
||||
"data": {
|
||||
"roomid": 23058,
|
||||
"fans": 297141,
|
||||
"red_notice": -1
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"cmd": "ROOM_SILENT_ON",
|
||||
"data": {
|
||||
"type": "level",
|
||||
"level": 20,
|
||||
"second": -1
|
||||
},
|
||||
"roomid": 1029
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"cmd": "USER_TOAST_MSG",
|
||||
"data": {
|
||||
"op_type": 1,
|
||||
"uid": 1781654,
|
||||
"username": "renbye",
|
||||
"guard_level": 3,
|
||||
"is_show": 0
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"cmd": "WELCOME",
|
||||
"data": {
|
||||
"uid": 3173595,
|
||||
"uname": "百杜Paido",
|
||||
"is_admin": false,
|
||||
"svip": 1
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"cmd": "WELCOME_GUARD",
|
||||
"data": {
|
||||
"uid": 3007159,
|
||||
"username": "goodbyecaroline",
|
||||
"guard_level": 3
|
||||
}
|
||||
}
|
@ -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
1
rest/.gitignore
vendored
@ -1 +0,0 @@
|
||||
src/test/resources/config.json
|
@ -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'
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
@ -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)
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
@ -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
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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))
|
||||
}
|
||||
)
|
@ -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)
|
@ -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>()
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
package com.hiczp.bilibili.rest
|
||||
|
||||
internal fun Any.println() = println(this)
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
org.slf4j.simpleLogger.defaultLogLevel=trace
|
@ -1,4 +1 @@
|
||||
rootProject.name = 'bilibili-api'
|
||||
|
||||
include 'rest'
|
||||
include 'websocket'
|
||||
|
29
src/main/kotlin/com/hiczp/bilibili/api/BilibiliAPI.kt
Normal file
29
src/main/kotlin/com/hiczp/bilibili/api/BilibiliAPI.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
@ -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)
|
@ -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()
|
15
src/main/kotlin/com/hiczp/bilibili/api/utils/Logging.kt
Normal file
15
src/main/kotlin/com/hiczp/bilibili/api/utils/Logging.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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'
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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!!
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
@ -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")
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
org.slf4j.simpleLogger.defaultLogLevel=trace
|
Loading…
Reference in New Issue
Block a user