减少依赖引入

This commit is contained in:
czp3009 2019-03-27 12:26:47 +08:00
parent b6a000b905
commit c546606a66
11 changed files with 390 additions and 146 deletions

View File

@ -47,11 +47,7 @@ val code = bilibiliApiException.commonResponse.code
# 登录和登出
(Bilibili oauth2 v3)
登陆和登出均为异步方法, 需要在协程上下文中执行.
如果所使用的语言无法正确调用 `suspend` 方法, 可以使用 `loginFuture` 方法来替代, 它会返回一个 Java8 `CompletableFuture`.
`logoutFuture` 同理.
登陆和登出均为异步方法, 需要在协程上下文中执行(接下去不会特地强调这一点).
```kotlin
runBlocking {
@ -97,9 +93,13 @@ login(username, password, challenge, secCode, validate)
(注意, 极验会根据滑动的轨迹来识别人机, 所以要为最终用户打开一个 WebView 来进行真人操作而不能自动完成. 极验最终返回的是一个 jsonp, 里面包含以上三个参数, 详见极验接入文档).
注意, `BilibiliClient` 不能严格保证线程安全, 如果在登出的同时进行登录操作可能引发错误.
注意, `BilibiliClient` 不能严格保证线程安全, 如果在登出的同时进行登录操作可能引发错误(想要这么做的人一定脑子瓦特了).
登陆后, 可以访问全部 API.
登陆后, 可以访问全部 API(注意, 有一些明显不需要登录的 API 也有可能需要登录).
由于各种需要登陆的 API 在未登录时返回的 `code` 并不统一, 因此没有办法做自动 `token` 刷新, 自己看着办.
在真实的客户端上, 每次一打开 APP 就会访问[个人信息 API](#获取个人信息)来确定 `token` 是否仍然可用, 这就是 B站 自己的解决方案.
# 访问 API
不要问文档, 用自动补全(心)来感受. 以下给出几个示例
@ -141,7 +141,7 @@ val videoPlayUrl = bilibiliClient.playerAPI.videoPlayUrl(aid = 41517911, cid = 7
https://www.bilibili.com/video/av44541340/?p=2
实际上就是选择了该 `aid` 下的第二个 `cid`.
实际上就是选择了该 `aid` 下的第二个 `cid`(注意, 参数里使用的 `cid` 不是这个 p 的序号, 它也是一个很长的数字).
简单的来说, `aid``cid` 加在一起才能表示一个视频流(为什么 `cid` 不能直接表示一个视频我也不知道).
@ -334,13 +334,15 @@ danmakuList.forEach {
}
```
注意, 弹幕的解析是惰性的, `danmakuList` 是一个 `Sequence`. 如果同时持有很多未用完的 `danmakuList` 的引用可能会造成大量内存浪费.
客户端的弹幕屏蔽设置是对弹幕中的 `user` 属性做的. 而实际上 `danmaku.user` 是一个字符串.
这个字符串是 用户ID 的 `CRC32` 的校验和.
众所周知, 一切 hash 算法都有冲突的问题. 这也就意味着, 屏蔽一个用户的同时可能屏蔽掉了多个与该用户 hash 值相同的用户.
在另一方面, 通过这个 `CRC32` 校验和进行用户 ID 反查, 将查询到多个可能的用户, 因此无法确定一条弹幕到底是哪个用户发送的.
在另一方面, 通过这个 `CRC32` 校验和进行用户 ID 反查, 将查询到多个可能的用户, 因此无法完全确定一条弹幕到底是哪个用户发送的.
如果想获得发送这条弹幕的所有可能的用户的 ID, 可以通过以下方法:
@ -378,7 +380,7 @@ bilibiliClient.mainAPI.sendDanmaku(aid = 40675923, cid = 71438168, progress = 22
如果不确定视频的长度, 需要从[视频播放地址的 API](#获取视频播放地址) 中的 `data.timelength` 来获得, 单位也是毫秒.
## 获取直播弹幕
刚进入直播间时, 看到的十条弹幕实际上是最近的历史弹幕, 通过以下方式来获取
刚进入直播间时, 立即看到的十条弹幕实际上是最近的历史弹幕, 通过以下方式来获取
```kotlin
bilibiliClient.liveAPI.roomMessage(roomId).await()
@ -481,19 +483,19 @@ onClose = { liveClient, closeReason ->
liveClient.sendMessage("我上我也行").await()
```
注意, 除了弹幕超长(普通用户为 20 个 Unicode 字符, 老爷, 会员可以额外加长)会导致抛出异常, 其他情况都会正常返回.
注意, 除了弹幕超长(普通用户为 20 个 Unicode 字符, 老爷, 会员可以额外加长)会导致抛出异常, 其他情况都会正常返回(`code` 为 0).
完全正常返回时, 返回内容中的 `message` 为一个空字符串.
完全正常返回时(弹幕正确的被发送了), 返回内容中的 `message` 为一个空字符串.
如果不为空字符串, 则表示不完全正常
例如返回内容的 `message` 为 "msg repeat" 则表示短时间重复发送相同的弹幕而被服务器拒绝, 但是返回的 `code` 0.
例如返回内容的 `message` 为 "msg repeat" 则表示短时间重复发送相同的弹幕而被服务器拒绝, 但是返回的 `code` 确实是 0.
其他情况诸如包含特殊字符, 包含不文明词语等均会导致不完全正常的返回.
正常返回时, 客户端都会将这条弹幕显示到屏幕上, 如果不是完全正常的, 那么这条弹幕就只有自己能看见(刷新后也会消失).
正常返回时, 就算不完全正常, 客户端也会将这条弹幕显示到屏幕上, 如果不是完全正常的, 那么这条弹幕就只有自己能看见(刷新后也会消失).
所以请额外判断返回的 `message` 是否为空字符串.
需要额外判断返回的 `message` 是否为空字符串来确认这条弹幕有没有被正确发送.
# License
GPL V3

View File

@ -16,7 +16,7 @@ buildscript {
}
group = 'com.hiczp'
version = '0.1.0'
version = '1.0.0'
description = 'Bilibili Android client API library for Kotlin'
apply plugin: 'kotlin'
@ -32,8 +32,6 @@ dependencies {
compile group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: kotlin_coroutines_version
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-jdk8
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-jdk8', version: kotlin_coroutines_version
}
compileKotlin {
kotlinOptions {
@ -64,7 +62,7 @@ dependencies {
// https://mvnrepository.com/artifact/com.jakewharton.retrofit/retrofit2-kotlin-coroutines-adapter
compile group: 'com.jakewharton.retrofit', name: 'retrofit2-kotlin-coroutines-adapter', version: '0.9.2'
// https://mvnrepository.com/artifact/com.squareup.okhttp3/logging-interceptor
compile group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '3.13.1'
compile group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '3.14.0'
}
//ktor
@ -75,12 +73,6 @@ dependencies {
compile group: 'io.ktor', name: 'ktor-client-cio', version: ktor_version
}
//utils
dependencies {
// https://mvnrepository.com/artifact/commons-io/commons-io
compile group: 'commons-io', name: 'commons-io', version: '2.6'
}
//checksum
dependencies {
// https://mvnrepository.com/artifact/com.hiczp/crc32-crack
@ -90,5 +82,5 @@ dependencies {
//unit test
dependencies {
// https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter
testCompile group: 'org.junit.jupiter', name: 'junit-jupiter', version: '5.4.0'
testCompile group: 'org.junit.jupiter', name: 'junit-jupiter', version: '5.4.1'
}

View File

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

View File

@ -22,8 +22,6 @@ import com.hiczp.bilibili.api.retrofit.interceptor.SortAndSignInterceptor
import com.hiczp.bilibili.api.vc.VcAPI
import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
import io.ktor.http.cio.websocket.CloseReason
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.future.future
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@ -299,15 +297,6 @@ class BilibiliClient(
}
}
/**
* 返回 Future 类型的 login 接口, 用于兼容 Java, 下同
*/
fun loginFuture(username: String, password: String,
challenge: String?,
secCode: String?,
validate: String?
) = GlobalScope.future { login(username, password, challenge, secCode, validate) }
/**
* 登出
* 这个方法不一定是线程安全的, 登出的同时如果进行登陆操作可能引发错误
@ -322,11 +311,6 @@ class BilibiliClient(
loginResponse = null
}
/**
* 返回 Future 类型的 logout 接口
*/
fun logoutFuture() = GlobalScope.future { logout() }
private val sortAndSignInterceptor = SortAndSignInterceptor(billingClientProperties.appSecret)
private val httpLoggingInterceptor = HttpLoggingInterceptor().setLevel(logLevel)
private inline fun <reified T : Any> createAPI(

View File

@ -1,13 +1,33 @@
package com.hiczp.bilibili.api
import com.hiczp.bilibili.api.thirdpart.commons.BoundedInputStream
import io.ktor.util.InternalAPI
import org.apache.commons.io.IOUtils
import org.apache.commons.io.input.BoundedInputStream
import org.apache.commons.io.input.BoundedReader
import kotlinx.io.errors.EOFException
import java.io.InputStream
import java.nio.charset.Charset
fun InputStream.readFully(length: Int) = IOUtils.readFully(this, length)!!
//减少包引入
//https://github.com/apache/commons-io/blob/master/src/main/java/org/apache/commons/io/IOUtils.java
fun InputStream.readFully(length: Int): ByteArray {
if (length < 0) {
throw IllegalArgumentException("Length must not be negative: $length")
}
val byteArray = ByteArray(length)
var remaining = length
while (remaining > 0) {
val count = read(byteArray, length - remaining, remaining)
if (count == -1) break
remaining -= count
}
val actual = length - remaining
if (actual != length) {
throw EOFException("Length to read: $length actual: $actual")
}
return byteArray
}
/**
* 以大端模式从流中读取一个 int
@ -27,9 +47,6 @@ fun InputStream.readInt(): Int {
@UseExperimental(ExperimentalUnsignedTypes::class)
fun InputStream.readUInt() = readInt().toUInt()
fun InputStream.boundedReader(maxCharsFromTargetReader: Int, charset: Charset = Charsets.UTF_8) =
BoundedReader(reader(charset), maxCharsFromTargetReader)
fun InputStream.bounded(size: Long) = BoundedInputStream(this, size)
@UseExperimental(ExperimentalUnsignedTypes::class)

View File

@ -25,100 +25,96 @@ import javax.xml.stream.XMLStreamConstants
* @see com.hiczp.bilibili.api.danmaku.DanmakuAPI.list
*/
@Suppress("SpellCheckingInspection")
class DanmakuParser {
companion object {
/**
* 解析弹幕文件
*
* @param inputStream 输入流, 可以指向任何位置
*
* @return 返回 flags map 弹幕序列. 注意, 原始的弹幕顺序是按发送时间来排的, 而非播放器时间.
*/
@JvmStatic
fun parse(inputStream: InputStream): Pair<Map<Long, Int>, Sequence<Danmaku>> {
//Json 的长度
val jsonLength = inputStream.readUInt()
object DanmakuParser {
/**
* 解析弹幕文件
*
* @param inputStream 输入流, 可以指向任何位置
*
* @return 返回 flags map 弹幕序列. 注意, 原始的弹幕顺序是按发送时间来排的, 而非播放器时间.
*/
fun parse(inputStream: InputStream): Pair<Map<Long, Int>, Sequence<Danmaku>> {
//Json 的长度
val jsonLength = inputStream.readUInt()
//弹幕ID-Flag
val danmakuFlags = HashMap<Long, Int>()
//gson 会从 reader 中自行缓冲 1024 个字符, 这会导致额外的字符被消费. 因此要限制其读取数量
//流式解析 Json
with(JsonReader(inputStream.bounded(jsonLength).reader())) {
beginObject()
while (hasNext()) {
when (nextName()) {
"dmflags" -> {
beginArray()
//弹幕ID-Flag
val danmakuFlags = HashMap<Long, Int>()
//gson 会从 reader 中自行缓冲 1024 个字符, 这会导致额外的字符被消费. 因此要限制其读取数量
//流式解析 Json
with(JsonReader(inputStream.bounded(jsonLength).reader())) {
beginObject()
while (hasNext()) {
when (nextName()) {
"dmflags" -> {
beginArray()
while (hasNext()) {
var danmakuId = 0L
var flag = 0
beginObject()
while (hasNext()) {
var danmakuId = 0L
var flag = 0
beginObject()
while (hasNext()) {
when (nextName()) {
"dmid" -> danmakuId = nextLong()
"flag" -> flag = nextInt()
else -> skipValue()
}
when (nextName()) {
"dmid" -> danmakuId = nextLong()
"flag" -> flag = nextInt()
else -> skipValue()
}
endObject()
danmakuFlags[danmakuId] = flag
}
endArray()
}
else -> skipValue()
}
}
endObject()
}
//json 解析完毕后, 剩下的内容是一个 gzip 压缩过的 xml
val reader = GZIPInputStream(inputStream).reader()
//流式解析 xml
val xmlEventReader = XMLInputFactory.newInstance().createXMLEventReader(reader)
//lazy sequence
val danmakus = sequence {
var startD = false //之前解析到的 element 是否是 d
var p: String? = null //之前解析到的 p 的值
while (xmlEventReader.hasNext()) {
val event = xmlEventReader.nextEvent()
when (event.eventType) {
XMLStreamConstants.START_ELEMENT -> {
with(event.asStartElement()) {
startD = name.localPart == "d"
if (startD) {
p = getAttributeByName(P).value
}
}
}
XMLStreamConstants.CHARACTERS -> {
//如果前一个解析到的是 d 标签, 那么此处得到的一定是 d 标签的 body
if (startD) {
val danmaku = with(StringTokenizer(p, ",")) {
Danmaku(
nextToken().toLong(),
nextToken(),
nextToken().toLong(),
nextToken().toInt(),
nextToken().toInt(),
nextToken().toInt(),
nextToken().toLong(),
nextToken(),
nextToken(),
event.asCharacters().data
)
}
yield(danmaku)
}
endObject()
danmakuFlags[danmakuId] = flag
}
endArray()
}
else -> skipValue()
}
}
return danmakuFlags to danmakus
endObject()
}
//常量, 用于加快速度
@JvmStatic
private val P = QName("p")
//json 解析完毕后, 剩下的内容是一个 gzip 压缩过的 xml
val reader = GZIPInputStream(inputStream).reader()
//流式解析 xml
val xmlEventReader = XMLInputFactory.newInstance().createXMLEventReader(reader)
//lazy sequence
val danmakus = sequence {
var startD = false //之前解析到的 element 是否是 d
var p: String? = null //之前解析到的 p 的值
while (xmlEventReader.hasNext()) {
val event = xmlEventReader.nextEvent()
when (event.eventType) {
XMLStreamConstants.START_ELEMENT -> {
with(event.asStartElement()) {
startD = name.localPart == "d"
if (startD) {
p = getAttributeByName(P).value
}
}
}
XMLStreamConstants.CHARACTERS -> {
//如果前一个解析到的是 d 标签, 那么此处得到的一定是 d 标签的 body
if (startD) {
val danmaku = with(StringTokenizer(p, ",")) {
Danmaku(
nextToken().toLong(),
nextToken(),
nextToken().toLong(),
nextToken().toInt(),
nextToken().toInt(),
nextToken().toInt(),
nextToken().toLong(),
nextToken(),
nextToken(),
event.asCharacters().data
)
}
yield(danmaku)
}
}
}
}
}
return danmakuFlags to danmakus
}
//常量, 用于加快速度
private val P = QName("p")
}

View File

@ -42,7 +42,7 @@ fun FormBody.Builder.addAllEncoded(formBody: FormBody): FormBody.Builder {
return this
}
typealias ParamExpression = Pair<String, () -> String?>
internal typealias ParamExpression = Pair<String, () -> String?>
internal inline fun Array<out ParamExpression>.forEachNonNull(action: (String, String) -> Unit) {
forEach { (name, valueExpression) ->

View File

@ -0,0 +1,253 @@
package com.hiczp.bilibili.api.thirdpart.commons;
//用于减少包引入
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import java.io.IOException;
import java.io.InputStream;
/**
* This is a stream that will only supply bytes up to a certain length - if its
* position goes above that, it will stop.
* <p>
* This is useful to wrap ServletInputStreams. The ServletInputStream will block
* if you try to read content from it that isn't there, because it doesn't know
* whether the content hasn't arrived yet or whether the content has finished.
* So, one of these, initialized with the Content-length sent in the
* ServletInputStream's header, will stop it blocking, providing it's been sent
* with a correct content length.
*
* @since 2.0
*/
public class BoundedInputStream extends InputStream {
private static int EOF = -1;
/**
* the wrapped input stream
*/
private final InputStream in;
/**
* the max length to provide
*/
private final long max;
/**
* the number of bytes already returned
*/
private long pos = 0;
/**
* the marked position
*/
private long mark = EOF;
/**
* flag if close should be propagated
*/
private boolean propagateClose = true;
/**
* Creates a new <code>BoundedInputStream</code> that wraps the given input
* stream and limits it to a certain size.
*
* @param in The wrapped input stream
* @param size The maximum number of bytes to return
*/
public BoundedInputStream(final InputStream in, final long size) {
// Some badly designed methods - eg the servlet API - overload length
// such that "-1" means stream finished
this.max = size;
this.in = in;
}
/**
* Creates a new <code>BoundedInputStream</code> that wraps the given input
* stream and is unlimited.
*
* @param in The wrapped input stream
*/
public BoundedInputStream(final InputStream in) {
this(in, EOF);
}
/**
* Invokes the delegate's <code>read()</code> method if
* the current position is less than the limit.
*
* @return the byte read or -1 if the end of stream or
* the limit has been reached.
* @throws IOException if an I/O error occurs
*/
@Override
public int read() throws IOException {
if (max >= 0 && pos >= max) {
return EOF;
}
final int result = in.read();
pos++;
return result;
}
/**
* Invokes the delegate's <code>read(byte[])</code> method.
*
* @param b the buffer to read the bytes into
* @return the number of bytes read or -1 if the end of stream or
* the limit has been reached.
* @throws IOException if an I/O error occurs
*/
@Override
public int read(final byte[] b) throws IOException {
return this.read(b, 0, b.length);
}
/**
* Invokes the delegate's <code>read(byte[], int, int)</code> method.
*
* @param b the buffer to read the bytes into
* @param off The start offset
* @param len The number of bytes to read
* @return the number of bytes read or -1 if the end of stream or
* the limit has been reached.
* @throws IOException if an I/O error occurs
*/
@Override
public int read(final byte[] b, final int off, final int len) throws IOException {
if (max >= 0 && pos >= max) {
return EOF;
}
final long maxRead = max >= 0 ? Math.min(len, max - pos) : len;
final int bytesRead = in.read(b, off, (int) maxRead);
if (bytesRead == EOF) {
return EOF;
}
pos += bytesRead;
return bytesRead;
}
/**
* Invokes the delegate's <code>skip(long)</code> method.
*
* @param n the number of bytes to skip
* @return the actual number of bytes skipped
* @throws IOException if an I/O error occurs
*/
@Override
public long skip(final long n) throws IOException {
final long toSkip = max >= 0 ? Math.min(n, max - pos) : n;
final long skippedBytes = in.skip(toSkip);
pos += skippedBytes;
return skippedBytes;
}
/**
* {@inheritDoc}
*/
@Override
public int available() throws IOException {
if (max >= 0 && pos >= max) {
return 0;
}
return in.available();
}
/**
* Invokes the delegate's <code>toString()</code> method.
*
* @return the delegate's <code>toString()</code>
*/
@Override
public String toString() {
return in.toString();
}
/**
* Invokes the delegate's <code>close()</code> method
* if {@link #isPropagateClose()} is {@code true}.
*
* @throws IOException if an I/O error occurs
*/
@Override
public void close() throws IOException {
if (propagateClose) {
in.close();
}
}
/**
* Invokes the delegate's <code>reset()</code> method.
*
* @throws IOException if an I/O error occurs
*/
@Override
public synchronized void reset() throws IOException {
in.reset();
pos = mark;
}
/**
* Invokes the delegate's <code>mark(int)</code> method.
*
* @param readlimit read ahead limit
*/
@Override
public synchronized void mark(final int readlimit) {
in.mark(readlimit);
mark = pos;
}
/**
* Invokes the delegate's <code>markSupported()</code> method.
*
* @return true if mark is supported, otherwise false
*/
@Override
public boolean markSupported() {
return in.markSupported();
}
/**
* Indicates whether the {@link #close()} method
* should propagate to the underling {@link InputStream}.
*
* @return {@code true} if calling {@link #close()}
* propagates to the <code>close()</code> method of the
* underlying stream or {@code false} if it does not.
*/
public boolean isPropagateClose() {
return propagateClose;
}
/**
* Set whether the {@link #close()} method
* should propagate to the underling {@link InputStream}.
*
* @param propagateClose {@code true} if calling
* {@link #close()} propagates to the <code>close()</code>
* method of the underlying stream or
* {@code false} if it does not.
*/
public void setPropagateClose(final boolean propagateClose) {
this.propagateClose = propagateClose;
}
}

View File

@ -11,7 +11,7 @@ class DanmakuTest {
runBlocking {
//著名的炮姐视频 你指尖跃动的电光是我此生不变的信仰
val responseBody = bilibiliClient.danmakuAPI.list(aid = 810872, oid = 1176840).await()
timer {
printTimeMillis {
DanmakuParser.parse(responseBody.byteStream()).second.forEach {
println("[${it.time}] ${it.calculatePossibleUserIds()} ${it.content}")
}

View File

@ -29,7 +29,7 @@ class FetchReplyTest {
val aid = 150998L
val bilibiliClient = BilibiliClient(logLevel = HttpLoggingInterceptor.Level.BASIC)
timer {
printTimeMillis {
var total: Int? = null
var next: Long = 0
runBlocking {
@ -44,7 +44,7 @@ class FetchReplyTest {
//如果没有评论则不做进一步操作
if (total == null) {
println("<NoReply>")
return@timer
return@printTimeMillis
}
val pages = list {

View File

@ -1,11 +1,11 @@
package com.hiczp.bilibili.api.test
import kotlin.system.measureTimeMillis
/**
* 土制切面
* 输出执行时间
*/
inline fun timer(block: () -> Unit) {
val start = System.currentTimeMillis()
block()
val end = System.currentTimeMillis()
println("Done in ${end - start} ms")
inline fun printTimeMillis(block: () -> Unit) {
val time = measureTimeMillis(block)
println("Done in $time ms")
}