diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..603b523 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/chuni/ChuniData.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/chuni/ChuniData.kt new file mode 100644 index 0000000..f91e549 --- /dev/null +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/chuni/ChuniData.kt @@ -0,0 +1,86 @@ +package io.github.skydynamic.maiproberplus.core.data.chuni + +import android.content.Context +import io.github.skydynamic.maiproberplus.Application +import io.github.skydynamic.maiproberplus.core.prober.client +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File + +val JSON = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +class ChuniData { + @Serializable + data class SongDifficulty( + val difficulty: Int, + val level: String, + @SerialName("level_value") val levelValue: Float, + @SerialName("note_designer") val noteDesigner: String, + val version: Int, + val kanji: String = "", + val star: Int = 0, + ) + + @Serializable + data class SongInfo( + val id: Int, val title: String, val artist: String, val genre: String, + val bpm: Int, val version: Int, val difficulties: List + ) + + @Serializable + data class MusicDetail( + val name: String, val level: Float, + val score: Int, val rating: Float, + val version: Int, val rankType: ChuniEnums.RankType, + val diff: ChuniEnums.Difficulty, val fullComboType: ChuniEnums.FullComboType, + val clearType: ChuniEnums.ClearType, val fullChainType: ChuniEnums.FullChainType + ) + + @Serializable + data class LxnsSongListResponse(val songs: List) + + companion object { + var CHUNI_SONG_LIST = readChuniSongList() + + @OptIn(DelicateCoroutinesApi::class) + fun syncMaimaiSongList() { + val context = Application.application + var listFile = File(context.filesDir, "chuni_song_list.json") + + GlobalScope.launch(Dispatchers.IO) { + val result = + client.get("https://maimai.lxns.net/api/v0/chunithm/song/list?notes=true") + listFile.deleteOnExit() + listFile.createNewFile() + val bufferedWriter = + context.openFileOutput("chuni_song_list.json", Context.MODE_PRIVATE) + .bufferedWriter() + bufferedWriter.write(result.bodyAsText()) + bufferedWriter.close() + } + + CHUNI_SONG_LIST = readChuniSongList() + } + + private fun readChuniSongList(): List { + return JSON.decodeFromString( + Application.application.getFilesDirInputStream("chuni_song_list.json") + .bufferedReader().use { it.readText() } + ).songs + } + + fun getSongIdFromTitle(title: String): Int { + return CHUNI_SONG_LIST.find { it.title == title }?.id ?: -1 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/chuni/ChuniEnums.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/chuni/ChuniEnums.kt new file mode 100644 index 0000000..a80a195 --- /dev/null +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/chuni/ChuniEnums.kt @@ -0,0 +1,87 @@ +package io.github.skydynamic.maiproberplus.core.data.chuni + +import kotlinx.serialization.Serializable + +class ChuniEnums { + @Serializable + enum class Difficulty(val diffName: String, val diffIndex: Int) { + BASIC("Basic", 0), + ADVANCED("Advanced", 1), + EXPERT("Expert", 2), + MASTER("Master", 3), + ULTIMA("Ultima", 4), + WORLDSEND("World's End", 5), + RECENT("Recent", 6); + + companion object { + @JvmStatic + fun getDifficultyWithName(diffName: String): Difficulty { + for (difficulty in entries) { + if (difficulty.diffName.lowercase() == diffName.lowercase()) { + return difficulty + } + } + throw IllegalArgumentException("No such difficulty") + } + } + } + + @Serializable + enum class ClearType(val type: String) { + CATASTROPHY("catastrophy"), + ABSOLUTEP("absolutep"), + ABSOLUTE("absolute"), + HARD("hard"), + CLEAR("clear"), + FAILED("failed"); + } + + @Serializable + enum class FullComboType(val type: String) { + NULL(""), + AJC("alljusticecritical"), + AJ("alljustice"), + FC("fullcombo"); + } + + @Serializable + enum class FullChainType(val type: String) { + NULL(""), + FC("fullchain"), + GFC("fullchain2") + } + + @Serializable + enum class RankType( + val rank: String, + private val intRange: IntRange, + ) { + D("d", 0..499999), + C("c", 500000..599999), + B("b", 600000..699999), + BB("bb", 700000..799999), + BBB("bbb", 800000..899999), + A("a", 900000..924999), + AA("aa", 925000..949999), + AAA("aaa", 950000..974999), + S("s", 975000..989999), + SP("sp", 990000..999999), + SS("ss", 1000000..1004999), + SSP("ssp", 1005000..1007499), + SSS("sss", 1007500..1008999), + SSSP("sssp", 1009000..1010000); + + companion object { + @JvmStatic + fun getRankTypeByScore(score: Int): RankType { + var returnValue = D + for (rank in entries) { + if (score in rank.intRange) { + returnValue = rank + } + } + return returnValue + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiData.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiData.kt index be042b0..98fa983 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiData.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiData.kt @@ -2,9 +2,7 @@ package io.github.skydynamic.maiproberplus.core.data.maimai import android.content.Context import io.github.skydynamic.maiproberplus.Application -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.HttpTimeout +import io.github.skydynamic.maiproberplus.core.prober.client import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.DelicateCoroutinesApi @@ -21,13 +19,6 @@ val JSON = Json { encodeDefaults = true } -val client = HttpClient(CIO) { - install(HttpTimeout) { - requestTimeoutMillis = 30000 - connectTimeoutMillis = 30000 - } -} - class MaimaiData { @Serializable data class Notes( @@ -41,10 +32,13 @@ class MaimaiData { @Serializable data class SongDiffculty( - val type: MaimaiEnums.SongType, val difficulty: Int, val level: String, + val type: MaimaiEnums.SongType, + val difficulty: Int, + val level: String, @SerialName("level_value") val levelValue: Float, @SerialName("note_designer") val noteDesigner: String, - val version: Int, val notes: Notes + val version: Int, + val notes: Notes ) @Serializable @@ -62,8 +56,8 @@ class MaimaiData { val score: Float, val dxScore: Int, val rating: Int, val version: Int, val type: MaimaiEnums.SongType, val diff: MaimaiEnums.Difficulty, - val clearType: MaimaiEnums.ClearType, val syncType: MaimaiEnums.SyncType, - val specialClearType: MaimaiEnums.SpecialClearType + val rankType: MaimaiEnums.RankType, val syncType: MaimaiEnums.SyncType, + val fullComboType: MaimaiEnums.FullComboType ) @Serializable diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiEnums.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiEnums.kt index 946cea8..41284e0 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiEnums.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/data/maimai/MaimaiEnums.kt @@ -33,9 +33,9 @@ class MaimaiEnums { } @Serializable - enum class ClearType( - val clearName: String, - private val closedFloatingPointRange: ClosedFloatingPointRange, + enum class RankType( + val rank: String, + private val scoreRange: ClosedFloatingPointRange, ) { D("d", 0.0000..49.9999), C("c", 50.0000..59.9999), @@ -54,11 +54,11 @@ class MaimaiEnums { companion object { @JvmStatic - fun getClearTypeByScore(score: Float): ClearType { + fun getRankTypeByScore(score: Float): RankType { var returnValue = D - for (clearType in entries) { - if (score in clearType.closedFloatingPointRange) { - returnValue = clearType + for (rank in entries) { + if (score in rank.scoreRange) { + returnValue = rank } } return returnValue @@ -67,25 +67,13 @@ class MaimaiEnums { } @Serializable - enum class SpecialClearType(val sepcialClearName: String) { + enum class FullComboType(val typeName: String) { @SerialName("") NULL(""), FC("fc"), FCP("fcp"), AP("ap"), APP("app"); - - companion object { - @JvmStatic - fun getSpecialClearType(specialClearName: String): SpecialClearType { - for (specialClearType in entries) { - if (specialClearType.sepcialClearName == specialClearName.lowercase()) { - return specialClearType - } - } - throw IllegalArgumentException("No such special clear type") - } - } } @Serializable @@ -97,17 +85,5 @@ class MaimaiEnums { FSP("fsp"), FDX("fsd"), FDXP("fsdp"); - - companion object { - @JvmStatic - fun getSyncType(syncName: String): SyncType { - for (syncType in entries) { - if (syncType.syncName == syncName.lowercase()) { - return syncType - } - } - return NULL - } - } } } \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/DivingFishProberUtil.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/DivingFishProberUtil.kt index 2f478ac..3929a0d 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/DivingFishProberUtil.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/DivingFishProberUtil.kt @@ -5,7 +5,9 @@ import io.github.skydynamic.maiproberplus.GlobalViewModel import io.ktor.client.request.headers import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.contentType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -32,11 +34,15 @@ class DivingFishProberUtil : IProberUtil { sendMessageToUi("开始获取Maimai数据并上传到水鱼查分器") val scores = getMaimaiPageData(authUrl) + if (scores.isEmpty()) { + return + } + val postScores = scores.map { DivingFishScoreUploadBody( achievements = it.score, dxScore = it.dxScore, - fc = it.specialClearType.sepcialClearName, + fc = it.fullComboType.typeName, fs = it.syncType.syncName, levelIndex = it.diff.diffIndex, title = it.name, @@ -61,4 +67,25 @@ class DivingFishProberUtil : IProberUtil { } GlobalViewModel.maimaiHooking = false } + + override suspend fun uploadChunithmProberData( + importToken: String, + authUrl: String + ) { + sendMessageToUi("开始获取中二节奏数据并上传到水鱼查分器") + fetchChuniScores(authUrl) { diff, body -> + val recentParam = if (diff.diffName.lowercase().contains("recent")) "?recent=1" else "" + client.post("https://www.diving-fish.com/api/chunithmprober/player/update_records_html$recentParam") { + headers { + append("Import-Token", importToken) + append(HttpHeaders.ContentType, "text/plain") + } + contentType(ContentType.Text.Plain) + setBody(body) + } + } + sendMessageToUi("上传中二节奏成绩到水鱼查分器完成") + Log.d("DivingFishProberUtil", "上传完毕") + GlobalViewModel.chuniHooking = false + } } \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/IProberUtil.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/IProberUtil.kt index 2403843..e8aab53 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/IProberUtil.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/IProberUtil.kt @@ -1,81 +1,8 @@ package io.github.skydynamic.maiproberplus.core.prober -import android.util.Log -import io.github.skydynamic.maiproberplus.GlobalViewModel -import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiData.MusicDetail -import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiEnums -import io.github.skydynamic.maiproberplus.core.utils.ParseScorePageUtil -import io.github.skydynamic.maiproberplus.core.utils.WechatRequestUtil.WX_WINDOWS_UA -import io.ktor.client.HttpClient -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.get -import io.ktor.client.request.headers -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpHeaders -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - interface IProberUtil { suspend fun updateAccountInfo(importToken: String) {} suspend fun uploadMaimaiProberData(importToken: String, authUrl: String) {} suspend fun uploadChunithmProberData(importToken: String, authUrl: String) {} - - suspend fun getMaimaiPageData(authUrl: String) : List { - val scores = mutableListOf() - - client.get(authUrl) { - getDefaultWahlapRequestBuilder() - } - - val result = client.get("https://maimai.wahlap.com/maimai-mobile/home/") - - if (result.bodyAsText().contains("错误")) { - Log.e("ProberUtil", "登录失败, 抓取成绩停止") - return emptyList() - } - - for (diff in MaimaiEnums.Difficulty.entries) { - Log.i("ProberUtil", "开始抓取${diff.diffName}成绩") - with(client) { - val scoreResp: HttpResponse = get( - "https://maimai.wahlap.com/maimai-mobile/record/" + - "musicGenre/search/?genre=99&diff=${diff.diffIndex}" - ) - val body = scoreResp.bodyAsText() - - val data = Regex("([\\s\\S]*)") - .find(body)?.groupValues?.get(1)?.replace("\\s+/g", " ") - - scores.addAll(ParseScorePageUtil.parseMaimai(data ?: "", diff)) - } - } - - return scores - } - - fun sendMessageToUi(message: String) { - CoroutineScope(Dispatchers.Main).launch { - GlobalViewModel.sendAndShowMessage(message) - } - } } -private fun HttpRequestBuilder.getDefaultWahlapRequestBuilder() { - headers { - append(HttpHeaders.Connection, "keep-alive") - append("Upgrade-Insecure-Requests", "1") - append(HttpHeaders.UserAgent, WX_WINDOWS_UA) - append( - HttpHeaders.Accept, "text/html,application/xhtml+xml,application/xml;q=0.9," + - "image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - ) - append("Sec-Fetch-Site", "none") - append("Sec-Fetch-Mode", "navigate") - append("Sec-Fetch-User", "?1") - append("Sec-Fetch-Dest", "document") - append(HttpHeaders.AcceptEncoding, "gzip, deflate, br") - append(HttpHeaders.AcceptLanguage, "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7") - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/LxnsProberUtil.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/LxnsProberUtil.kt index 9adf853..4883947 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/LxnsProberUtil.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/LxnsProberUtil.kt @@ -2,6 +2,7 @@ package io.github.skydynamic.maiproberplus.core.prober import android.util.Log import io.github.skydynamic.maiproberplus.GlobalViewModel +import io.github.skydynamic.maiproberplus.core.data.chuni.ChuniData import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiData import io.ktor.client.call.body import io.ktor.client.request.header @@ -19,22 +20,48 @@ class LxnsProberUtil : IProberUtil { private val baseApiUrl = "https://maimai.lxns.net" @Serializable - data class LxnsResponse( - val success: Boolean, - val code: Int, - val message: String = "", - val data: List = listOf() + open class LxnsResponse( + val success: Boolean = false, + val code: Int = 0, + val message: String = "" ) @Serializable - data class LxnsScoreBody( + data class LxnsMaimaiResponse( + val data: List = listOf() + ) : LxnsResponse() + + @Serializable + data class LxnsChuniResponse( + val data: List = listOf() + ) : LxnsResponse() + + @Serializable + data class LxnsChuniScoreBody( + val id: Int, + @SerialName("song_name") val songName: String = "", + val level: String = "", + @SerialName("level_index") val levelIndex: Int, + val score: Int, + val rating: Float = 0.0F, + @SerialName("over_power") val overPower: Float = 0.0F, + val clear: String, + @SerialName("full_combo") val fullCombo: String = "", + @SerialName("full_chain") val fullChain: String = "", + val rank: String = "", + @SerialName("play_time") val playTime: String = "", + @SerialName("upload_time") val uploadTime: String = "" + ) + + @Serializable + data class LxnsMaimaiScoreBody( val id: Int, @SerialName("song_name") val songName: String = "", val level: String = "", @SerialName("level_index") val levelIndex: Int, val achievements: Float, - val fc: String? = "", - val fs: String? = "", + val fc: String = "", + val fs: String = "", @SerialName("dx_score") val dxScore: Int, @SerialName("dx_rating") val dxRating: Float = 0.0F, val rate: String = "", @@ -44,28 +71,35 @@ class LxnsProberUtil : IProberUtil { ) @Serializable - data class LxnsRequestBody(val scores: List) + data class LxnsMaimaiRequestBody(val scores: List) + + @Serializable + data class LxnsChuniRequestBody(val scores: List) override suspend fun uploadMaimaiProberData( importToken: String, authUrl: String ) { - sendMessageToUi("开始获取Maimai数据并上传到落雪查分器") + sendMessageToUi("开始获取舞萌数据并上传到落雪查分器") val scores = getMaimaiPageData(authUrl) + if (scores.isEmpty()) { + return + } + val postScores = scores.map { - LxnsScoreBody( + LxnsMaimaiScoreBody( id = MaimaiData.getSongIdFromTitle(it.name), levelIndex = it.diff.diffIndex, achievements = it.score, - fc = it.specialClearType.sepcialClearName, + fc = it.fullComboType.typeName, fs = it.syncType.syncName, dxScore = it.dxScore.toInt(), type = it.type.type ) } - val body = Json.encodeToString(LxnsRequestBody(postScores)) + val body = Json.encodeToString(LxnsMaimaiRequestBody(postScores)) val postResponse = client.post("$baseApiUrl/api/v0/user/maimai/player/scores") { setBody(body) @@ -75,14 +109,54 @@ class LxnsProberUtil : IProberUtil { } } - val postScoreResponseBody = postResponse.body() + val postScoreResponseBody = postResponse.body() if (postScoreResponseBody.success) { - sendMessageToUi("落雪查分器上传完毕") + sendMessageToUi("上传舞萌成绩到落雪查分器成功") Log.d("LxnsProberUtil", "上传完毕") } else { - sendMessageToUi("成绩上传到落雪查分器失败: ${postScoreResponseBody.message}") + sendMessageToUi("舞萌成绩上传到落雪查分器失败: ${postScoreResponseBody.message}") Log.e("LxnsProberUtil", "上传失败: ${postScoreResponseBody.message}") } GlobalViewModel.maimaiHooking = false } + + override suspend fun uploadChunithmProberData(importToken: String, authUrl: String) { + sendMessageToUi("开始获取中二节奏数据并上传到落雪查分器") + val scores = getChuniPageData(authUrl) + + if (scores.isEmpty()) { + return + } + + val postScores = scores.map { + LxnsChuniScoreBody( + id = ChuniData.getSongIdFromTitle(it.name), + levelIndex = it.diff.diffIndex, + score = it.score, + clear = it.clearType.type, + fullCombo = it.fullComboType.type, + fullChain = it.fullChainType.type + ) + } + + val body = Json.encodeToString(LxnsChuniRequestBody(postScores)) + + val postResponse = client.post("$baseApiUrl/api/v0/user/chunithm/player/scores") { + setBody(body) + header("X-User-Token", importToken) + headers { + append(HttpHeaders.ContentType, "application/json") + } + } + + val postScoreResponseBody = postResponse.body() + if (postScoreResponseBody.success) { + sendMessageToUi("上传中二节奏成绩到落雪查分器成功") + Log.d("LxnsProberUtil", "上传完毕") + } else { + sendMessageToUi("上传中二成绩成绩到落雪查分器失败: ${postScoreResponseBody.message}") + Log.e("LxnsProberUtil", "上传失败: ${postScoreResponseBody.message}") + } + GlobalViewModel.chuniHooking = false + } } \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/Utils.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/Utils.kt index 3a6fe07..b53de0d 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/Utils.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/prober/Utils.kt @@ -1,11 +1,34 @@ package io.github.skydynamic.maiproberplus.core.prober +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import io.github.skydynamic.maiproberplus.GlobalViewModel +import io.github.skydynamic.maiproberplus.core.data.chuni.ChuniData +import io.github.skydynamic.maiproberplus.core.data.chuni.ChuniEnums +import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiData +import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiEnums +import io.github.skydynamic.maiproberplus.core.utils.ParseScorePageUtil +import io.github.skydynamic.maiproberplus.core.utils.WechatRequestUtil.WX_WINDOWS_UA import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.cookies.get +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.setCookie import io.ktor.serialization.kotlinx.json.json val client = HttpClient(CIO) { @@ -19,4 +42,119 @@ val client = HttpClient(CIO) { install(HttpCookies) { storage = AcceptAllCookiesStorage() } +} + +val chuniUrls = listOf( + listOf("record/musicGenre/sendBasic", "record/musicGenre/basic"), + listOf("record/musicGenre/sendAdvanced", "record/musicGenre/advanced"), + listOf("record/musicGenre/sendExpert", "record/musicGenre/expert"), + listOf("record/musicGenre/sendMaster", "record/musicGenre/master"), + listOf("record/musicGenre/sendUltima", "record/musicGenre/ultima"), + listOf(null, "record/worldsEndList/"), + listOf(null, "home/playerData/ratingDetailRecent/") +) + +fun sendMessageToUi(message: String) { + CoroutineScope(Dispatchers.Main).launch { + GlobalViewModel.sendAndShowMessage(message) + } +} + +suspend fun getMaimaiPageData(authUrl: String) : List { + val scores = mutableListOf() + + client.get(authUrl) { + getDefaultWahlapRequestBuilder() + } + + val result = client.get("https://maimai.wahlap.com/maimai-mobile/home/") + + if (result.bodyAsText().contains("错误")) { + sendMessageToUi("获取舞萌成绩失败: 登录失败") + Log.e("ProberUtil", "登录失败, 抓取成绩停止") + return emptyList() + } + + for (diff in MaimaiEnums.Difficulty.entries) { + Log.i("ProberUtil", "开始抓取${diff.diffName}成绩") + with(client) { + val scoreResp = get( + "https://maimai.wahlap.com/maimai-mobile/record/" + + "musicGenre/search/?genre=99&diff=${diff.diffIndex}" + ) + val body = scoreResp.bodyAsText() + + val data = Regex("([\\s\\S]*)") + .find(body)?.groupValues?.get(1)?.replace("\\s+/g", " ") + + scores.addAll(ParseScorePageUtil.parseMaimai(data ?: "", diff)) + } + } + return scores +} + +suspend fun getChuniPageData(authUrl: String) : List { + val scores = mutableListOf() + fetchChuniScores(authUrl) { diff, body -> + scores.addAll(ParseScorePageUtil.parseChuni(body, diff)) + } + return scores +} + +suspend fun fetchChuniScores( + authUrl: String, + processBody: suspend (ChuniEnums.Difficulty, String) -> Unit +) { + val result = client.get(authUrl) { + getDefaultWahlapRequestBuilder() + } + + if (result.bodyAsText().contains("错误")) { + Log.e("ProberUtil", "登录公众号失败") + sendMessageToUi("获取中二节奏成绩失败: 登录公众号失败") + return + } + + val token = result.setCookie()["_t"]?.value + + for (diff in ChuniEnums.Difficulty.entries) { + val url = chuniUrls[diff.diffIndex] + + Log.i("ProberUtil", "开始抓取${diff.diffName}成绩") + + with(client) { + if (url[0] != null) { + post("https://chunithm.wahlap.com/mobile/${url[0]}") { + headers { + append(HttpHeaders.ContentType, "application/x-www-form-urlencoded") + } + contentType(ContentType.Application.FormUrlEncoded) + setBody("genre=99&token=$token") + } + } + + val resp: HttpResponse = get("https://chunithm.wahlap.com/mobile/${url[1]}") + val body = resp.bodyAsText() + + processBody(diff, body) + } + } +} + +private fun HttpRequestBuilder.getDefaultWahlapRequestBuilder() { + headers { + append(HttpHeaders.Connection, "keep-alive") + append("Upgrade-Insecure-Requests", "1") + append(HttpHeaders.UserAgent, WX_WINDOWS_UA) + append( + HttpHeaders.Accept, "text/html,application/xhtml+xml,application/xml;q=0.9," + + "image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" + ) + append("Sec-Fetch-Site", "none") + append("Sec-Fetch-Mode", "navigate") + append("Sec-Fetch-User", "?1") + append("Sec-Fetch-Dest", "document") + append(HttpHeaders.AcceptEncoding, "gzip, deflate, br") + append(HttpHeaders.AcceptLanguage, "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7") + } } \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/proxy/handle/InterceptHandler.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/proxy/handle/InterceptHandler.kt index db174b9..0740b73 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/proxy/handle/InterceptHandler.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/proxy/handle/InterceptHandler.kt @@ -3,6 +3,7 @@ package io.github.skydynamic.maiproberplus.core.proxy.handle import io.github.skydynamic.maiproberplus.GlobalViewModel import io.github.skydynamic.maiproberplus.core.config.ConfigStorage import io.github.skydynamic.maiproberplus.core.prober.ProberPlatform +import io.github.skydynamic.maiproberplus.core.prober.sendMessageToUi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -29,10 +30,15 @@ object InterceptHandler { GlobalViewModel.maimaiHooking = true proberUtil.uploadMaimaiProberData(token, target) } + } else if (target.contains("chunithm")) { + GlobalScope.launch(Dispatchers.IO) { + GlobalViewModel.chuniHooking = true + proberUtil.uploadChunithmProberData(token, target) + } } } else { CoroutineScope(Dispatchers.Main).launch { - proberUtil.sendMessageToUi("token为空") + sendMessageToUi("token为空") } } } diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/core/utils/ParseScorePageUtil.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/core/utils/ParseScorePageUtil.kt index f6b4be4..79057ae 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/core/utils/ParseScorePageUtil.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/core/utils/ParseScorePageUtil.kt @@ -1,10 +1,13 @@ package io.github.skydynamic.maiproberplus.core.utils +import android.annotation.SuppressLint +import io.github.skydynamic.maiproberplus.core.data.chuni.ChuniData +import io.github.skydynamic.maiproberplus.core.data.chuni.ChuniEnums import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiData import io.github.skydynamic.maiproberplus.core.data.maimai.MaimaiEnums import org.jsoup.Jsoup -fun calcScore(score: String, songLevel: Float): Int { +fun calcMaimaiScore(score: String, songLevel: Float): Int { var formatScore = score.replace("%", "").toFloat() val multiplierFactor = when (formatScore) { in 10.0000..19.9999 -> 0.016 // D @@ -34,6 +37,23 @@ fun calcScore(score: String, songLevel: Float): Int { return (songLevel * multiplierFactor * formatScore).toInt() } +@SuppressLint("DefaultLocale") +fun calcChuniScore(score: Int, songLevel: Float): Float { + return when { + score >= 1009000 -> songLevel + 2.15f + score >= 1007500 -> songLevel + 2.0f + (score - 1007500) / 100.0 * 0.01 + score >= 1005000 -> songLevel + 1.5f + (score - 1005000) / 500.0 * 0.1 + score >= 1000000 -> songLevel + 1.0f + (score - 1000000) / 1000.0 * 0.1 + score >= 990000 -> songLevel + (score - 990000) / 2500.0 * 0.1 + score >= 975000 -> songLevel + (score - 975000) / 2500.0 * 0.1 + score >= 925000 -> songLevel - 3.0f + score >= 900000 -> songLevel - 5.0f + score >= 800000 -> (songLevel - 5.0f) / 2 + score >= 500000 -> 0.0f + else -> 0.0f + }.let { String.format("%.2f", it).toFloat() } +} + fun extractDxScoreNum(input: String): Int? { val regex = Regex("""\d{1,3}(,\d{3})*""") val matchResult = regex.find(input) @@ -50,7 +70,6 @@ object ParseScorePageUtil { if (html.isEmpty()) { return emptyList() } - val musicList = ArrayList() val document = Jsoup.parse(html) @@ -73,10 +92,10 @@ object ParseScorePageUtil { val musicClearTypes = musicCard.getElementsByClass("h_30 f_r") - val musicClearType = MaimaiEnums.ClearType - .getClearTypeByScore(musicScore.replace("%", "").toFloat()) + val musicRankType = MaimaiEnums.RankType + .getRankTypeByScore(musicScore.replace("%", "").toFloat()) var musicSyncType = MaimaiEnums.SyncType.NULL - var musicSpecialClearType = MaimaiEnums.SpecialClearType.NULL + var musicFullComboType = MaimaiEnums.FullComboType.NULL val isDeluxe = musicCard.getElementsByClass("music_kind_icon") .attr("src") @@ -90,7 +109,7 @@ object ParseScorePageUtil { if (res != null) res.difficulties.standard[difficulty.diffIndex].levelValue else -1f } - val musicRating = calcScore(musicScore, musicLevel) + val musicRating = calcMaimaiScore(musicScore, musicLevel) val musicVersion = res?.version ?: 10000 for (musicClearTypeElement in musicClearTypes) { @@ -103,10 +122,10 @@ object ParseScorePageUtil { "fsp" -> musicSyncType = MaimaiEnums.SyncType.FSP "fdx" -> musicSyncType = MaimaiEnums.SyncType.FDX "fdxp" -> musicSyncType = MaimaiEnums.SyncType.FDXP - "fc" -> musicSpecialClearType = MaimaiEnums.SpecialClearType.FC - "fcp" -> musicSpecialClearType = MaimaiEnums.SpecialClearType.FCP - "ap" -> musicSpecialClearType = MaimaiEnums.SpecialClearType.AP - "app" -> musicSpecialClearType = MaimaiEnums.SpecialClearType.APP + "fc" -> musicFullComboType = MaimaiEnums.FullComboType.FC + "fcp" -> musicFullComboType = MaimaiEnums.FullComboType.FCP + "ap" -> musicFullComboType = MaimaiEnums.FullComboType.AP + "app" -> musicFullComboType = MaimaiEnums.FullComboType.APP } } } @@ -116,11 +135,84 @@ object ParseScorePageUtil { musicScoreNum, musicDxScoreNum ?: 0, musicRating, musicVersion, musicType, difficulty, - musicClearType, musicSyncType, - musicSpecialClearType + musicRankType, musicSyncType, + musicFullComboType ) ) } return musicList } + + fun parseChuni( + html: String, + difficulty: ChuniEnums.Difficulty + ): List { + if (html.isEmpty()) { + return emptyList() + } + val musicList = ArrayList() + + val document = Jsoup.parse(html) + + val musicListBox = document.getElementsByClass("musiclist_box") + for (musicListBoxElement in musicListBox) { + val highScore = musicListBoxElement.getElementsByClass("play_musicdata_highscore") + if (highScore.text().isEmpty()) { + continue + } + + val musicName = musicListBoxElement.getElementsByClass("music_title").text() + val musicScore = highScore[0].tagName("span").text() + val musicScoreNum = musicScore.replace("分数:", "").replace(",", "").toInt() + + var musicDifficulty = ChuniEnums.Difficulty.BASIC + if (difficulty == ChuniEnums.Difficulty.RECENT) { + val cl = musicListBoxElement.attr("class") + val regex = Regex("bg_(\\w+)") + val matchResult = regex.find(cl)?.groups?.get(1)?.value ?: "" + musicDifficulty = ChuniEnums.Difficulty.getDifficultyWithName(matchResult) + } + + val res = ChuniData.CHUNI_SONG_LIST.find { it.title == musicName } + var musicLevel = 0F + val musicRating = calcChuniScore(musicScoreNum, musicLevel) + if (res != null) { + musicLevel = res.difficulties[musicDifficulty.diffIndex].levelValue + } + + val musicVersion = res?.version ?: 10000 + + var clearType = ChuniEnums.ClearType.FAILED + var fullComboType = ChuniEnums.FullComboType.NULL + var fullChainType = ChuniEnums.FullChainType.NULL + + val icons = musicListBoxElement.getElementsByClass("play_musicdata_icon") + if (icons.isNotEmpty()) { + for (icon in icons) { + val regex = Regex(".*icon_(.*?)?.png?.*") + val value = regex.find(icon.attr("src"))?.groupValues?.get(1) + if (value != null) { + when(value) { + "fullcombo" -> fullComboType = ChuniEnums.FullComboType.FC + "alljustice" -> fullComboType = ChuniEnums.FullComboType.AJ + "alljusticecritical" -> fullComboType = ChuniEnums.FullComboType.AJC + "fullchain" -> fullChainType = ChuniEnums.FullChainType.FC + "fullchain2" -> fullChainType = ChuniEnums.FullChainType.GFC + "clear" -> clearType = ChuniEnums.ClearType.CLEAR + "hard" -> clearType = ChuniEnums.ClearType.HARD + "absolute" -> clearType = ChuniEnums.ClearType.ABSOLUTE + "absolutep" -> clearType = ChuniEnums.ClearType.ABSOLUTEP + "catastrophy" -> clearType = ChuniEnums.ClearType.CATASTROPHY + } + } + } + musicList.add(ChuniData.MusicDetail( + musicName, musicLevel, musicScoreNum, musicRating, + musicVersion, ChuniEnums.RankType.getRankTypeByScore(musicScoreNum), + difficulty, fullComboType, clearType, fullChainType + )) + } + } + return musicList + } } \ No newline at end of file diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/Dialogs.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/Dialogs.kt index ef9cb6f..b1f4f6d 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/Dialogs.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/Dialogs.kt @@ -94,11 +94,11 @@ fun DownloadDialog( val job = scope.async(Dispatchers.IO) { val response = httpClient.get(meta.fileDownloadUrl) if (response.status.value == 200) { + File(application.filesDir, meta.fileName).deleteOnExit() val outputStream = application.getFilesDirOutputStream(meta.fileName) outputStream.bufferedWriter().use { it.write(response.bodyAsText()) } - completedFiles++ finish = completedFiles.toFloat() / totalFiles } @@ -122,7 +122,8 @@ fun DownloadDialog( ) { Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { LinearProgressIndicator( progress = { finish }, diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/SyncCompose.kt b/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/SyncCompose.kt index 39a5c79..853fbff 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/SyncCompose.kt +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/ui/compose/SyncCompose.kt @@ -79,6 +79,10 @@ fun SyncCompose() { FileDownloadMeta( "maimai_song_list.json", "https://maimai.lxns.net/api/v0/maimai/song/list?notes=true" + ), + FileDownloadMeta( + "chuni_song_list.json", + "https://maimai.lxns.net/api/v0/chunithm/song/list" ) ) ) { @@ -103,7 +107,8 @@ fun SyncCompose() { .padding(15.dp) .height(50.dp), onClick = { - if (!context.filesDir.resolve("maimai_song_list.json").exists()) { + if (!context.filesDir.resolve("maimai_song_list.json").exists() || + !context.filesDir.resolve("chuni_song_list.json").exists()) { viewModel.openInitDialog = true } if (!globalViewModel.isVpnServiceRunning) { diff --git a/app/src/main/java/io/github/skydynamic/maiproberplus/vpn/tunnel/HttpCapturerTunnel.java b/app/src/main/java/io/github/skydynamic/maiproberplus/vpn/tunnel/HttpCapturerTunnel.java index cb7b43c..cf2ad8e 100644 --- a/app/src/main/java/io/github/skydynamic/maiproberplus/vpn/tunnel/HttpCapturerTunnel.java +++ b/app/src/main/java/io/github/skydynamic/maiproberplus/vpn/tunnel/HttpCapturerTunnel.java @@ -40,7 +40,7 @@ protected void beforeSend(ByteBuffer buffer) { Log.d(TAG, "HTTP url: " + url); // If it's a auth redirect request, catch it - if (url.startsWith("http://tgk-wcaime.wahlap.com/wc_auth/oauth/callback/maimai-dx")) { + if (url.contains("tgk-wcaime.wahlap.com")) { Log.d(TAG, "Auth request caught!"); InterceptHandler.onAuthHook(url, Application.application.configManager.getConfig()); } diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755..036d09b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755..036d09b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,5 @@ - - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78..ce7dd7b 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..10e07ad Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d..9f822f5 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d6..68f4151 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..9420b7c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611d..78357bf 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a307..c1bdd74 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c80a411 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a695..54dcf23 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77..921b594 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..96d3985 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f50..06266ca 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d642..7b2050b 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..81730a3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae3..fafe5cd 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file