├── extensions ├── paintboard │ ├── .gitignore │ ├── README.md │ └── main │ │ └── kotlin │ │ └── org │ │ └── hoshino9 │ │ └── luogu │ │ └── paintboard │ │ ├── Pos.kt │ │ ├── config.kt │ │ ├── PhotoProvider.kt │ │ ├── Board.kt │ │ ├── Painter.kt │ │ ├── Color.kt │ │ ├── BoardProvider.kt │ │ └── PainterManager.kt ├── training │ ├── build.gradle.kts │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── hoshino9 │ │ │ └── luogu │ │ │ └── training │ │ │ ├── TrainingInfoPage.kt │ │ │ ├── ext.kt │ │ │ ├── TrainingListPage.kt │ │ │ ├── TrainingForm.kt │ │ │ └── Training.kt │ └── test │ │ └── kotlin │ │ └── TrainingTest.kt ├── photo │ ├── test │ │ ├── resources │ │ │ └── photo.png │ │ └── kotlin │ │ │ └── PhotoTest.kt │ └── main │ │ └── kotlin │ │ └── org │ │ └── hoshino9 │ │ └── luogu │ │ └── photo │ │ ├── PhotoListPage.kt │ │ ├── Photo.kt │ │ ├── PhotoContent.kt │ │ └── ext.kt ├── record │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── hoshino9 │ │ │ └── luogu │ │ │ └── record │ │ │ ├── DefaultRecord.kt │ │ │ ├── Record.kt │ │ │ ├── AbstractRecord.kt │ │ │ ├── ext.kt │ │ │ └── Solution.kt │ └── test │ │ └── kotlin │ │ └── RecordTest.kt ├── problem │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── hoshino9 │ │ │ └── luogu │ │ │ └── problem │ │ │ ├── Tags.kt │ │ │ ├── ext.kt │ │ │ ├── ProblemSearchConfig.kt │ │ │ ├── ProblemListPage.kt │ │ │ ├── Solution.kt │ │ │ ├── LoggedProblem.kt │ │ │ └── Problem.kt │ └── test │ │ └── kotlin │ │ └── ProblemTest.kt ├── paste │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── hoshino9 │ │ │ └── luogu │ │ │ └── paste │ │ │ ├── PasteList.kt │ │ │ ├── ext.kt │ │ │ └── Paste.kt │ └── test │ │ └── kotlin │ │ └── PasteTest.kt ├── contest │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── hoshino9 │ │ │ └── luogu │ │ │ └── contest │ │ │ ├── ContestListPage.kt │ │ │ ├── ext.kt │ │ │ ├── types.kt │ │ │ ├── ContestPage.kt │ │ │ └── Contest.kt │ └── test │ │ └── kotlin │ │ └── ContestTest.kt └── build.gradle.kts ├── gradle.properties ├── demo ├── problem-demo │ ├── build.gradle.kts │ └── main │ │ └── main.kt ├── submit-demo │ ├── build.gradle.kts │ └── main │ │ └── main.kt ├── build.gradle.kts └── core-demo │ └── main │ └── main.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── core ├── src │ ├── main │ │ └── kotlin │ │ │ └── org │ │ │ └── hoshino9 │ │ │ └── luogu │ │ │ ├── page │ │ │ ├── PageBuilder.kt │ │ │ ├── ListPage.kt │ │ │ ├── LuoGuPage.kt │ │ │ ├── BaseLuoGuPage.kt │ │ │ ├── AbstractLuoGuPage.kt │ │ │ ├── Page.kt │ │ │ └── DeprecatedLuoGuPage.kt │ │ │ ├── tag │ │ │ ├── LuoGuTag.kt │ │ │ └── IdLuoGuTag.kt │ │ │ ├── user │ │ │ ├── ext.kt │ │ │ ├── LoggedUser.kt │ │ │ ├── FollowList.kt │ │ │ └── User.kt │ │ │ ├── utils │ │ │ ├── utils.kt │ │ │ ├── json-utils.kt │ │ │ └── ktor-utils.kt │ │ │ ├── exceptions.kt │ │ │ ├── utils.kt │ │ │ ├── team │ │ │ └── Team.kt │ │ │ ├── test │ │ │ ├── test-utils.kt │ │ │ └── BaseTest.kt │ │ │ └── LuoGu.kt │ └── test │ │ └── kotlin │ │ ├── UserTest.kt │ │ ├── auto-refollow.kts │ │ └── login-script.kt └── build.gradle.kts ├── appveyor.yml ├── .gitignore ├── .github └── workflows │ └── blank.yml ├── settings.gradle.kts ├── .circleci └── config.yml ├── LICENSE ├── README.md ├── gradlew.bat └── gradlew /extensions/paintboard/.gitignore: -------------------------------------------------------------------------------- 1 | test/ -------------------------------------------------------------------------------- /extensions/paintboard/README.md: -------------------------------------------------------------------------------- 1 | # PaintBoard 2 | 3 | 洛谷的冬日绘板项目。 -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ktorVersion=1.2.6 2 | coroutinesVersion=1.3.2 -------------------------------------------------------------------------------- /extensions/training/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | compile(project(":extensions:problem")) 3 | } -------------------------------------------------------------------------------- /demo/problem-demo/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation(project(":extensions:problem")) 3 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HoshinoTented/LuoGuAPI/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /extensions/photo/test/resources/photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HoshinoTented/LuoGuAPI/HEAD/extensions/photo/test/resources/photo.png -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/PageBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | interface PageBuilder { 4 | fun build(): T 5 | } -------------------------------------------------------------------------------- /demo/submit-demo/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation(project(":extensions:record")) 3 | implementation(project(":extensions:problem")) 4 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/tag/LuoGuTag.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.tag 2 | 3 | interface LuoGuTag { 4 | companion object; 5 | val id: Int 6 | } -------------------------------------------------------------------------------- /extensions/record/main/kotlin/org/hoshino9/luogu/record/DefaultRecord.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.record 2 | 3 | open class DefaultRecord(override val rid : String) : AbstractRecord() -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/Pos.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | /** 4 | * x 为水平 5 | * y 为垂直 6 | */ 7 | data class Pos(val x: Int, val y: Int) -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/tag/IdLuoGuTag.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.tag 2 | 3 | open class IdLuoGuTag(override val id: Int) : LuoGuTag { 4 | override fun toString(): String { 5 | return id.toString() 6 | } 7 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | JAVA_HOME: C:\Program Files\Java\jdk1.8.0 3 | # 4 | 5 | build_script: 6 | - gradlew assemble --info --warning-mode=all --stacktrace --no-daemon 7 | # 8 | 9 | artifacts: 10 | - path: "luogu/build/libs" 11 | name: "luogu" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # idea 2 | .idea/ 3 | 4 | # gradle 5 | .gradle/ 6 | 7 | # output 8 | build/ 9 | out/ 10 | 11 | # test resources 12 | verify.png 13 | user.properties 14 | luogu-* 15 | 16 | # wiki 17 | wiki 18 | 19 | # generate 20 | gen/ 21 | 22 | # paintboard 23 | cookies.properties -------------------------------------------------------------------------------- /.github/workflows/blank.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Build 13 | run: | 14 | ./gradlew assemble --info --warning-mode=all --stacktrace --no-daemon 15 | -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/config.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | import org.hoshino9.luogu.baseUrl 4 | import org.hoshino9.luogu.domain 5 | 6 | const val paintApi = "$baseUrl/paintBoard" 7 | const val boardApi = "$baseUrl/paintBoard" 8 | const val boardWsApi = "wss://ws$domain/ws" -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/user/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("UserUtils") 2 | 3 | package org.hoshino9.luogu.user 4 | 5 | import org.hoshino9.luogu.LuoGu 6 | import org.hoshino9.luogu.LuoGuClient 7 | 8 | val LuoGuClient.currentUser: LoggedUserPage? 9 | get() { 10 | return LoggedUserPage(cookieUid?.toInt() ?: return null, this) 11 | } -------------------------------------------------------------------------------- /demo/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet 2 | 3 | allprojects { 4 | sourceSets { 5 | main { 6 | withConvention(KotlinSourceSet::class) { 7 | kotlin.srcDir("main") 8 | } 9 | 10 | resources.srcDir("resources") 11 | } 12 | } 13 | 14 | dependencies { 15 | implementation(project(":core")) 16 | } 17 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/utils/utils.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package org.hoshino9.luogu.utils 4 | 5 | import com.google.gson.JsonElement 6 | import io.ktor.client.HttpClient 7 | import kotlin.reflect.KClass 8 | 9 | typealias HttpClient = HttpClient 10 | 11 | const val USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36" 12 | 13 | -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/Tags.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.problem 2 | 3 | enum class Difficulty(val content: String) { 4 | Unknown("暂无评定"), 5 | Red("入门"), 6 | Orange("普及-"), 7 | Yellow("普及/提高-"), 8 | Green("普及+/提高"), 9 | Blue("提高+/省选-"), 10 | Purple("省选/NOI-"), 11 | Black("NOI/NOI+/CTSC") 12 | } 13 | 14 | enum class Type(val id: String) { 15 | LuoGu("P"), 16 | CodeForces("CF"), 17 | AtCoder("AT"), 18 | SPOJ("SP"), 19 | UVA("UVA") 20 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/exceptions.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused", "CanBeParameter", "MemberVisibilityCanBePrivate", "FunctionName") 2 | 3 | package org.hoshino9.luogu 4 | 5 | import com.google.gson.JsonElement 6 | import okhttp3.Response 7 | import org.jsoup.nodes.Node 8 | 9 | open class IllegalStatusCodeException(val code: Int?, val msg: String) : IllegalStateException("$code: $msg") 10 | open class HTMLParseException(val node: Node?, val msg: String = "") : Exception(msg) -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/ListPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | import kotlin.math.ceil 4 | 5 | /** 6 | * 列表页面,仅用于数据类接口 7 | */ 8 | interface ListPage { 9 | companion object; 10 | 11 | /** 12 | * 搜索结果数量 13 | */ 14 | val count: Int 15 | 16 | /** 17 | * 每个显示元素数量 18 | */ 19 | val perPage: Int 20 | } 21 | 22 | /** 23 | * 根据搜索结果数量和每页显示数量推断总页数 24 | */ 25 | val ListPage.maxPageCount 26 | get() = run { 27 | ceil(count.toDouble() / perPage).toInt() 28 | } -------------------------------------------------------------------------------- /demo/problem-demo/main/main.kt: -------------------------------------------------------------------------------- 1 | import org.hoshino9.luogu.LuoGu 2 | import org.hoshino9.luogu.LuoGuClient 3 | import org.hoshino9.luogu.page.maxPageCount 4 | import org.hoshino9.luogu.problem.BaseProblem 5 | import org.hoshino9.luogu.problem.problemList 6 | 7 | suspend fun main() { 8 | val lg = LuoGuClient() 9 | val max = lg.problemList().maxPageCount 10 | val seq = sequence { 11 | (1..max).forEach { 12 | yieldAll(lg.problemList(it).result) 13 | } 14 | } 15 | 16 | seq.forEach { 17 | println(it.pid) 18 | } 19 | } -------------------------------------------------------------------------------- /core/src/test/kotlin/UserTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.runBlocking 2 | import org.hoshino9.luogu.test.BaseTest 3 | import org.hoshino9.luogu.test.printAllMember 4 | import org.hoshino9.luogu.user.currentUser 5 | import org.hoshino9.luogu.user.doFollow 6 | import org.junit.Test 7 | 8 | class UserTest : BaseTest() { 9 | @Test 10 | fun user() { 11 | runBlocking { 12 | client.currentUser?.run { 13 | printAllMember() 14 | followers().printAllMember() 15 | followings().printAllMember() 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/LuoGuPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | import com.google.gson.JsonObject 4 | 5 | /** 6 | * 洛谷页面接口 7 | * 8 | * [refresh] 应该重新获取网页内容,并更新 [feInjection] 9 | * 10 | * 事实上一般用不到这个 [refresh],主要是 [org.hoshino9.luogu.LuoGu] 的刷新问题, 11 | * 以后可能通过改善设计来绕过。 12 | */ 13 | interface LuoGuPage { 14 | companion object; 15 | 16 | val url: String 17 | val feInjection: JsonObject 18 | 19 | fun refresh() 20 | } 21 | 22 | val LuoGuPage.currentData: JsonObject get() = feInjection["currentData"].asJsonObject -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/BaseLuoGuPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | import com.google.gson.JsonObject 4 | import kotlinx.atomicfu.AtomicRef 5 | import kotlinx.atomicfu.atomic 6 | 7 | /** 8 | * 基础洛谷页面抽象类,提供了 [feInjection] 的默认实现。 9 | */ 10 | abstract class BaseLuoGuPage : LuoGuPage { 11 | protected val _feInjection: AtomicRef = atomic(null) 12 | override val feInjection: JsonObject 13 | @get:Synchronized get() { 14 | if (_feInjection.value == null) { 15 | refresh() 16 | } 17 | 18 | return _feInjection.value !! 19 | } 20 | } -------------------------------------------------------------------------------- /extensions/record/main/kotlin/org/hoshino9/luogu/record/Record.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.record 2 | 3 | import io.ktor.http.cio.websocket.WebSocketSession 4 | import okhttp3.WebSocket 5 | import org.hoshino9.luogu.LuoGu 6 | 7 | interface Record { 8 | companion object { 9 | fun message(rid: String) = """{"type":"join_channel","channel":"record.track","channel_param":"$rid"}""" 10 | 11 | @JvmName("newInstance") 12 | operator fun invoke(rid: String): Record { 13 | return DefaultRecord(rid) 14 | } 15 | } 16 | 17 | val rid: String 18 | suspend fun listen(client: LuoGu, listener: suspend WebSocketSession.() -> Unit) 19 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/utils.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MemberVisibilityCanBePrivate", "unused") 2 | 3 | package org.hoshino9.luogu 4 | 5 | import org.jsoup.nodes.Document 6 | 7 | const val domain = ".luogu.com.cn" 8 | const val baseUrl = "https://www$domain" 9 | const val wsUrl = "wss://ws$domain/ws" 10 | 11 | 12 | /** 13 | * 一个奇怪的Token, 似乎十分重要, 大部分操作都需要这个 14 | * @param page 任意一个**你谷**页面 15 | * @return 返回 `csrf-token` 16 | */ 17 | fun csrfTokenFromPage(page: Document): String { 18 | return page.head().getElementsByTag("meta").firstOrNull { it?.attr("name") == "csrf-token" }?.attr("content") 19 | ?: throw HTMLParseException(page) 20 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "luoguapi" 2 | 3 | include(":extensions", ":core", ":demo") 4 | 5 | val plugins = listOf( 6 | "contest" 7 | , "paintboard" 8 | , "photo" 9 | , "paste" 10 | , "problem" 11 | , "record" 12 | , "training" 13 | ).map { 14 | ":extensions:$it" 15 | }.toTypedArray() 16 | 17 | val demos = listOf( 18 | "core", 19 | "submit", 20 | "problem" 21 | ).map { 22 | ":demo:$it-demo" 23 | }.toTypedArray() 24 | 25 | include(*plugins) 26 | include(*demos) 27 | 28 | pluginManagement { 29 | repositories { 30 | if (System.getenv("CI").isNullOrBlank()) maven("https://maven.aliyun.com/repository/public") 31 | gradlePluginPortal() 32 | } 33 | } -------------------------------------------------------------------------------- /extensions/problem/test/kotlin/ProblemTest.kt: -------------------------------------------------------------------------------- 1 | import org.hoshino9.luogu.problem.* 2 | import org.hoshino9.luogu.test.BaseTest 3 | import org.hoshino9.luogu.test.printAllMember 4 | import org.junit.Test 5 | 6 | class ProblemTest : BaseTest() { 7 | @Test 8 | fun problemList() { 9 | client.problemList().printAllMember() 10 | } 11 | 12 | @Test 13 | fun problemInfo() { 14 | ProblemPageBuilder("P1000", client).build().problem.printAllMember() 15 | } 16 | 17 | @Test 18 | fun solutions() { 19 | SolutionPageBuilder("P1000", client).build().printAllMember() 20 | } 21 | 22 | // @Test 23 | // fun mark() { 24 | // runBlocking { 25 | // luogu.unmark("P3695") 26 | // } 27 | // } 28 | } -------------------------------------------------------------------------------- /extensions/paste/main/kotlin/org/hoshino9/luogu/paste/PasteList.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paste 2 | 3 | import org.hoshino9.luogu.baseUrl 4 | import org.hoshino9.luogu.page.AbstractLuoGuPage 5 | import org.hoshino9.luogu.utils.HttpClient 6 | 7 | class PasteList(val page: Int, client: HttpClient) : AbstractLuoGuPage(client) { 8 | override val url: String = "$baseUrl/paste?page=$page" 9 | 10 | private val data get() = feInjection["currentData"].asJsonObject["pastes"].asJsonObject 11 | 12 | val list: List 13 | get() { 14 | return data["result"].asJsonArray.map { 15 | PasteImpl(it.asJsonObject) 16 | } 17 | } 18 | 19 | val count: Int 20 | get() { 21 | return data["count"].asInt 22 | } 23 | } -------------------------------------------------------------------------------- /core/src/test/kotlin/auto-refollow.kts: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.runBlocking 2 | import org.hoshino9.luogu.LuoGu 3 | import org.hoshino9.luogu.LuoGuClient 4 | import org.hoshino9.luogu.user.currentUser 5 | import org.hoshino9.luogu.user.doFollow 6 | 7 | runBlocking { 8 | val luogu = LuoGuClient("your client id", "your uid".toInt()) 9 | val currentUser = luogu.currentUser ?: throw IllegalStateException("No login") 10 | 11 | tailrec suspend fun refollow(page: Int) { 12 | val list = currentUser.followers(page).result 13 | if (list.isEmpty()) return 14 | 15 | list.forEach { 16 | if (it.userRelationship == 1) println("Followed ${it.uid}") else { 17 | println("Following ${it.uid}") 18 | luogu.doFollow(it.uid) 19 | } 20 | } 21 | 22 | refollow(page + 1) 23 | } 24 | 25 | refollow(1) 26 | } -------------------------------------------------------------------------------- /demo/core-demo/main/main.kt: -------------------------------------------------------------------------------- 1 | import org.hoshino9.luogu.* 2 | import java.io.File 3 | import java.nio.file.Paths 4 | import java.util.Properties 5 | 6 | suspend fun main() { 7 | val properties = Properties().apply { 8 | load(File(rootPath).resolve("user.properties").inputStream()) 9 | } 10 | 11 | val account: String by properties 12 | val password: String by properties 13 | 14 | val lg = LuoGu() 15 | val tmpdir = Paths.get(System.getProperty("java.io.tmpdir")).resolve("verify.png") 16 | 17 | tmpdir.toFile() 18 | .outputStream() 19 | .write(lg.verifyCode()) 20 | 21 | println("Verify code was sent to your temp directory: ${tmpdir.toAbsolutePath()}") 22 | 23 | val code = readLine() !! 24 | 25 | lg.login(account, password, code) 26 | 27 | println("Cookies: ${lg.clientId.value}") 28 | } 29 | -------------------------------------------------------------------------------- /extensions/contest/main/kotlin/org/hoshino9/luogu/contest/ContestListPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.contest 2 | 3 | import org.hoshino9.luogu.baseUrl 4 | import org.hoshino9.luogu.page.AbstractLuoGuPage 5 | import org.hoshino9.luogu.utils.HttpClient 6 | import org.hoshino9.luogu.utils.emptyClient 7 | 8 | class ContestListPage(val page: Int = 1, client: HttpClient = emptyClient) : AbstractLuoGuPage(client) { 9 | override val url: String 10 | get() = "$baseUrl/contest/list?page=$page" 11 | 12 | private val data = feInjection["currentData"].asJsonObject["contests"].asJsonObject 13 | 14 | val count: Int 15 | get() = data["count"].asInt 16 | 17 | 18 | val contests: List 19 | get() { 20 | return data["result"].asJsonArray.map { 21 | BaseContestImpl(it) 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /extensions/training/main/kotlin/org/hoshino9/luogu/training/TrainingInfoPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.training 2 | 3 | import com.google.gson.JsonObject 4 | import org.hoshino9.luogu.baseUrl 5 | import org.hoshino9.luogu.page.AbstractLuoGuPage 6 | import org.hoshino9.luogu.page.currentData 7 | import org.hoshino9.luogu.utils.HttpClient 8 | import org.hoshino9.luogu.utils.delegate 9 | import org.hoshino9.luogu.utils.emptyClient 10 | 11 | class TrainingInfoPage(val id: Int, client: HttpClient = emptyClient) : AbstractLuoGuPage(client) { 12 | override val url: String = "$baseUrl/training/$id" 13 | 14 | val data = currentData.delegate 15 | private val training: JsonObject by data 16 | val canEdit: Boolean by data 17 | 18 | val info: TrainingInfo by lazy { 19 | TrainingInfoImpl(training) 20 | } 21 | } -------------------------------------------------------------------------------- /extensions/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet 2 | 3 | allprojects { 4 | sourceSets { 5 | val names = listOf(main, test) 6 | 7 | names.forEach { src -> 8 | src.configure { 9 | withConvention(KotlinSourceSet::class) { 10 | kotlin.srcDir("$name/kotlin") 11 | } 12 | 13 | resources.srcDir("$name/resources") 14 | } 15 | } 16 | } 17 | 18 | dependencies { 19 | compileOnly(project(":core")) 20 | testApi(project(":core")) 21 | testApi(kotlin("test-junit")) 22 | } 23 | } 24 | 25 | val createExt = task("createExt") { 26 | doLast { 27 | val name = project.properties["extName"] ?: throw Exception("Please give an extension name") 28 | 29 | file("$name/main/kotlin/org/hoshino9/luogu/$name").mkdirs() 30 | file("$name/test/kotlin").mkdirs() 31 | } 32 | } -------------------------------------------------------------------------------- /extensions/record/main/kotlin/org/hoshino9/luogu/record/AbstractRecord.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.record 2 | 3 | import io.ktor.client.features.websocket.ws 4 | import io.ktor.http.cio.websocket.* 5 | import org.hoshino9.luogu.LuoGu 6 | import org.hoshino9.luogu.wsUrl 7 | 8 | abstract class AbstractRecord : Record { 9 | override suspend fun listen(client: LuoGu, listener: suspend WebSocketSession.() -> Unit) { 10 | client.client.ws(wsUrl) { 11 | send(Record.message(rid)) 12 | listener() 13 | } 14 | } 15 | 16 | 17 | override fun toString(): String { 18 | return rid 19 | } 20 | 21 | override fun equals(other: Any?): Boolean { 22 | if (this === other) return true 23 | if (other !is AbstractRecord) return false 24 | return rid == other.rid 25 | } 26 | 27 | override fun hashCode() : Int { 28 | return rid.hashCode() 29 | } 30 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Java Gradle CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-java/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/openjdk:8-jdk 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | # Customize the JVM maximum heap limit 21 | JVM_OPTS: -Xmx3200m 22 | TERM: dumb 23 | 24 | steps: 25 | - checkout 26 | 27 | - run: 28 | name: Assemble 29 | command: ./gradlew assemble --info --stacktrace --warning-mode=all --no-daemon 30 | 31 | -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/team/Team.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.team 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.annotations.JsonAdapter 7 | import org.hoshino9.luogu.utils.Deserializable 8 | import java.lang.reflect.Type 9 | 10 | @JsonAdapter(BaseTeamImpl.Serializer::class) 11 | interface BaseTeam { 12 | companion object; 13 | val id: Int 14 | val name: String 15 | } 16 | 17 | data class BaseTeamImpl(override val id: Int, override val name: String) : BaseTeam { 18 | companion object Serializer : Deserializable(BaseTeam::class), JsonDeserializer { 19 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): BaseTeam { 20 | return context.deserialize(json, BaseTeamImpl::class.java) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/AbstractLuoGuPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | import com.google.gson.JsonObject 4 | import io.ktor.client.call.receive 5 | import kotlinx.atomicfu.update 6 | import kotlinx.coroutines.runBlocking 7 | import org.hoshino9.luogu.LuoGuClient 8 | import org.hoshino9.luogu.utils.HttpClient 9 | import org.hoshino9.luogu.utils.apiGet 10 | import org.hoshino9.luogu.utils.emptyClient 11 | import org.hoshino9.luogu.utils.json 12 | 13 | /** 14 | * 一般抽象洛谷页面抽象类,几乎所有的洛谷页面都需要继承这个抽象类。 15 | * 提供了 [refresh] 的默认实现。 16 | */ 17 | abstract class AbstractLuoGuPage(val client: HttpClient = emptyClient) : BaseLuoGuPage() { 18 | override fun refresh() { 19 | runBlocking { 20 | _feInjection.update { 21 | json(client.apiGet(url).receive()) 22 | } 23 | } 24 | } 25 | } 26 | 27 | abstract class AbstractLuoGuClientPage(override val client: LuoGuClient) : BaseMutablePage() -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/test/test-utils.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.test 2 | 3 | import org.hoshino9.luogu.LuoGu 4 | import org.hoshino9.luogu.utils.HttpClient 5 | import kotlin.reflect.KVisibility 6 | import kotlin.reflect.full.memberProperties 7 | 8 | fun Any.printAllMember() { 9 | information.run(::println) 10 | } 11 | 12 | val Any.information: String 13 | get() { 14 | return when (this) { 15 | is Byte, is Short, is Int, is Long, is Float, is Double, is Boolean, is Unit, is Char, is String -> toString() 16 | is Collection<*> -> this.joinToString(prefix = "[", postfix = "]") { it?.information.toString() } 17 | is Map<*, *> -> this.toString() 18 | is HttpClient, is LuoGu -> toString() 19 | else -> { 20 | this::class.memberProperties.filter { it.visibility == KVisibility.PUBLIC }.joinToString(prefix = "{", postfix = "}") { 21 | "${it.name} = ${it.call(this)?.information}" 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /demo/submit-demo/main/main.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.http.cio.websocket.Frame 2 | import io.ktor.http.cio.websocket.readText 3 | import org.hoshino9.luogu.LuoGuClient 4 | import org.hoshino9.luogu.problem.ProblemPageBuilder 5 | import org.hoshino9.luogu.record.Solution 6 | import org.hoshino9.luogu.record.postSolution 7 | import org.hoshino9.luogu.test.BaseTest 8 | 9 | suspend fun main() { 10 | val lg = object : BaseTest() {}.luogu 11 | val problem = ProblemPageBuilder("P1001", LuoGuClient()).build().problem 12 | 13 | println(problem) 14 | 15 | val sol = Solution(problem.pid, Solution.Language.Haskell.ordinal, """ 16 | main :: IO () 17 | main = sum <$> map read <$> words <$> getLine >>= print 18 | """.trim(), false) 19 | 20 | lg.postSolution(sol).listen(lg) { 21 | for (frame in incoming) { 22 | frame as Frame.Text 23 | 24 | val content = frame.readText() 25 | if ("heartbeat" in content) break 26 | 27 | println(content) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /extensions/paste/test/kotlin/PasteTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.runBlocking 2 | import org.hoshino9.luogu.paste.deletePaste 3 | import org.hoshino9.luogu.paste.newPaste 4 | import org.hoshino9.luogu.paste.pasteList 5 | import org.hoshino9.luogu.test.BaseTest 6 | import org.hoshino9.luogu.test.printAllMember 7 | import org.junit.Test 8 | 9 | class PasteTest : BaseTest() { 10 | @Test 11 | fun postPaste() { 12 | runBlocking { 13 | luogu.newPaste("qwq") 14 | } 15 | } 16 | 17 | @Test 18 | fun pasteList() { 19 | runBlocking { 20 | luogu.pasteList().list.forEach { 21 | it.printAllMember() 22 | } 23 | } 24 | } 25 | 26 | @Test 27 | fun deleteAll() { 28 | runBlocking { 29 | while (true) { 30 | val list = luogu.pasteList().list 31 | 32 | if (list.isEmpty()) break else { 33 | list.forEach { 34 | luogu.deletePaste(it.id) 35 | println("Deleted: ${it.id}") 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /extensions/photo/main/kotlin/org/hoshino9/luogu/photo/PhotoListPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.photo 2 | 3 | import com.google.gson.JsonObject 4 | import org.hoshino9.luogu.baseUrl 5 | import org.hoshino9.luogu.page.AbstractLuoGuPage 6 | import org.hoshino9.luogu.utils.HttpClient 7 | import org.hoshino9.luogu.utils.delegate 8 | 9 | class PhotoListPage(val page: Int, client: HttpClient) : AbstractLuoGuPage(client) { 10 | override val url: String get() = "$baseUrl/image?page=$page" 11 | 12 | private val data: JsonObject = feInjection["currentData"].asJsonObject 13 | private val images: JsonObject = data["images"].asJsonObject 14 | private val dataDelegate = data.delegate 15 | private val imagesDelegate = images.delegate 16 | 17 | val spaceUsage: Int by dataDelegate 18 | val spaceLimit: Int by dataDelegate 19 | val count: Int by imagesDelegate 20 | 21 | val list: List 22 | get() { 23 | return images["result"].asJsonArray.map { PhotoImpl(it.asJsonObject) } 24 | } 25 | } -------------------------------------------------------------------------------- /extensions/record/main/kotlin/org/hoshino9/luogu/record/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("RecordUtils") 2 | 3 | package org.hoshino9.luogu.record 4 | 5 | import com.google.gson.JsonObject 6 | import io.ktor.client.call.receive 7 | import org.hoshino9.luogu.LuoGu 8 | import org.hoshino9.luogu.utils.* 9 | 10 | /** 11 | * 提交题解 12 | * @param solution 题解对象 13 | * @return 返回 Record 对象 14 | * 15 | * @see Solution 16 | * @see Record 17 | */ 18 | suspend fun LuoGu.postSolution(solution: Solution, verifyCode: String = ""): Record { 19 | val json = JsonObject().apply { 20 | addProperty("verify", verifyCode) 21 | addProperty("enableO2", if (solution.enableO2) 1 else 0) 22 | addProperty("lang", solution.language) 23 | addProperty("code", solution.code) 24 | }.asParams 25 | 26 | return apiPost("fe/api/problem/submit/${solution.pid}") { 27 | referer("problem/${solution.pid}") 28 | body = json 29 | }.receive().let { 30 | json(it).run { 31 | val rid: String by delegate 32 | 33 | Record(rid) 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 HoshinoTented 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/PhotoProvider.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | /** 4 | * 图片提供者 5 | * 6 | * [current] 提供当前绘画的相对坐标和颜色 7 | * 8 | * [next] 使提供者移动到下一个坐标 9 | * 10 | * 抽象出 PhotoProvider 可以支持各种绘画策略 11 | */ 12 | interface PhotoProvider { 13 | fun current(): Pair 14 | fun next() 15 | } 16 | 17 | /** 18 | * 默认绘画策略:顺序式 19 | * 20 | * 会沿着垂直方向进行绘画 21 | * 22 | * @param photo 目标图片 23 | */ 24 | class DefaultPhotoProvider(val photo: Board) : PhotoProvider { 25 | private var offset = Pos(0, 0) 26 | private val currentColor: Int? 27 | get() { 28 | return photo[offset] 29 | } 30 | 31 | private fun nextPos() { 32 | offset = Pos(offset.x + 1, offset.y) 33 | 34 | if (photo.width == offset.x) { 35 | offset = Pos(0, offset.y + 1) 36 | } 37 | 38 | if (photo.height == offset.y) { 39 | offset = Pos(0, 0) 40 | } 41 | } 42 | 43 | override fun current(): Pair { 44 | return offset to currentColor 45 | } 46 | 47 | override fun next() { 48 | nextPos() 49 | } 50 | } -------------------------------------------------------------------------------- /extensions/photo/test/kotlin/PhotoTest.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.http.ContentType 2 | import kotlinx.coroutines.runBlocking 3 | import org.hoshino9.luogu.photo.photoList 4 | import org.hoshino9.luogu.photo.pushPhoto 5 | import org.hoshino9.luogu.test.BaseTest 6 | import org.hoshino9.luogu.test.printAllMember 7 | import org.junit.Test 8 | import java.io.File 9 | import java.nio.file.Paths 10 | 11 | class PhotoTest : BaseTest() { 12 | companion object { 13 | @JvmStatic 14 | fun main(args: Array) { 15 | runBlocking { 16 | PhotoTest().run { 17 | File("extensions/photo/test/resources/verify.png").outputStream().write(luogu.verifyCode()) 18 | 19 | print("Please input verify code: ") 20 | 21 | val code = readLine() !! 22 | 23 | luogu.pushPhoto( 24 | photo = PhotoTest::class.java.getResource("photo.png").toURI().run(::File), 25 | verifyCode = code, 26 | contentType = ContentType.Image.PNG 27 | ).run(::println) 28 | } 29 | } 30 | } 31 | } 32 | 33 | @Test 34 | fun photoList() { 35 | luogu.photoList(1).printAllMember() 36 | } 37 | } -------------------------------------------------------------------------------- /extensions/record/main/kotlin/org/hoshino9/luogu/record/Solution.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.record 2 | 3 | /** 4 | * 题解类 5 | * 6 | * @param pid 题目 ID 7 | * @param language 语言代码,可通过 [Solution.Language.ordinal] 获得 8 | * @param code 代码主体 9 | * @param enableO2 是否开启 O2 10 | */ 11 | data class Solution(val pid: String, val language: Int, val code: String, val enableO2: Boolean = false) { 12 | enum class Language(val fullName: String) { 13 | Auto("Auto Select"), 14 | Pascal("Pascal"), 15 | C("C"), 16 | Cpp("C++"), 17 | Cpp11("C++ 11"), 18 | SubmitAnswer("Submit Answer"), 19 | Python2("Python 2"), 20 | Python3("Python 3"), 21 | Java8("Java 8"), 22 | NodeJs("Node v8.9"), 23 | Shell("Shell"), 24 | Cpp14("C++ 14"), 25 | Cpp17("C++ 17"), 26 | Ruby("Ruby"), 27 | Go("Go"), 28 | Rust("Rust"), 29 | PHP7("PHP 7"), 30 | CS("C#"), 31 | VB("Visual Basic"), 32 | Haskell("Haskell"), 33 | KotlinNative("Kotlin/Native"), 34 | KotlinJVM("Kotlin/JVM"), 35 | Scala("Scala"), 36 | Perl("Perl"), 37 | PyPy2("PyPy 2"), 38 | PyPy3("PyPy 3"), 39 | WenYan("WenYan") 40 | } 41 | } -------------------------------------------------------------------------------- /extensions/record/test/kotlin/RecordTest.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.features.ClientRequestException 2 | import io.ktor.http.cio.websocket.Frame 3 | import io.ktor.http.cio.websocket.readText 4 | import kotlinx.coroutines.launch 5 | import kotlinx.coroutines.runBlocking 6 | import org.hoshino9.luogu.record.Record 7 | import org.hoshino9.luogu.test.BaseTest 8 | import org.hoshino9.luogu.utils.strData 9 | import org.junit.Test 10 | 11 | class RecordTest : BaseTest() { 12 | @Test 13 | fun record() { 14 | runBlocking { 15 | val job = launch { 16 | try { 17 | // luogu.postSolution(Solution("P1001", Solution.Language.Haskell.ordinal, "main = putStrLn \"Hello world!\"")) 18 | Record("28862889").listen(luogu) { 19 | for (frame in incoming) { 20 | if (frame is Frame.Text) { 21 | val text = frame.readText() 22 | 23 | if ("heartbeat" in text) break 24 | 25 | println(text) 26 | } 27 | } 28 | } 29 | } catch (e: ClientRequestException) { 30 | System.err.println(e.response.strData()) 31 | } 32 | } 33 | 34 | job.join() 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/Board.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | import io.ktor.client.request.get 4 | import org.hoshino9.luogu.baseUrl 5 | import org.hoshino9.luogu.utils.emptyClient 6 | import java.awt.image.BufferedImage 7 | 8 | /** 9 | * 绘板数据类 10 | * 注意:Board 是竖直方向的 11 | * 12 | * @param height 高度 13 | * @param width 宽度 14 | */ 15 | data class Board(val height: Int, val width: Int) { 16 | val board: Array> = Array(width) { 17 | Array(height) { 18 | null as Int? 19 | } 20 | } 21 | 22 | operator fun get(pos: Pos): Int? { 23 | return board[pos.x][pos.y] 24 | } 25 | 26 | operator fun set(pos: Pos, color: Int?) { 27 | board[pos.x][pos.y] = color 28 | } 29 | } 30 | 31 | /** 32 | * 将绘板转化为 BufferedImage 33 | */ 34 | val Board.image: BufferedImage 35 | get() { 36 | val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) 37 | 38 | board.forEachIndexed { x, line -> 39 | line.forEachIndexed inner@{ y, color -> 40 | image.setRGB(x, y, colors[color ?: return@inner].toRGB) 41 | } 42 | } 43 | 44 | return image 45 | } -------------------------------------------------------------------------------- /extensions/training/test/kotlin/TrainingTest.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.runBlocking 2 | import org.hoshino9.luogu.test.BaseTest 3 | import org.hoshino9.luogu.test.printAllMember 4 | import org.hoshino9.luogu.training.* 5 | import org.hoshino9.luogu.user.LoggedUserPage 6 | import org.hoshino9.luogu.user.UserPage 7 | import org.hoshino9.luogu.user.currentUser 8 | import org.junit.Test 9 | 10 | class TrainingTest : BaseTest() { 11 | @Test 12 | fun trainingList() { 13 | val page = TrainingListPageBuilder(1, TrainingListPageBuilder.Type.Official, luogu.client).build().apply { 14 | printAllMember() 15 | } 16 | 17 | val training = page.result.first().lift(luogu.client).apply { 18 | printAllMember() 19 | } 20 | } 21 | 22 | @Test 23 | fun newTraining() { 24 | runBlocking { 25 | val id = luogu.newTraining(TrainingForm.PersonalPublic("123", "456")) 26 | println(id) 27 | 28 | luogu.editTrainingProblems(id, listOf("P1001", "P1002")) 29 | } 30 | } 31 | 32 | @Test 33 | fun userTrainingList() { 34 | runBlocking { 35 | LoggedUserPage(luogu.uid.value.toInt(), client).trainingList().printAllMember() 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/Painter.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | import com.google.gson.JsonObject 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.call.receive 6 | import io.ktor.client.features.ClientRequestException 7 | import io.ktor.client.request.post 8 | import io.ktor.client.request.request 9 | import io.ktor.client.response.HttpResponse 10 | import io.ktor.http.HttpMethod 11 | import org.hoshino9.luogu.LuoGu 12 | import org.hoshino9.luogu.baseUrl 13 | import org.hoshino9.luogu.utils.* 14 | 15 | /** 16 | * 绘画者,最基础的单位 17 | */ 18 | data class Painter(val client: HttpClient, val id: Int) { 19 | suspend fun paint(pos: Pos, color: Int): String { 20 | val params = JsonObject().apply { 21 | addProperty("x", pos.x) 22 | addProperty("y", pos.y) 23 | addProperty("color", color) 24 | } 25 | 26 | try { 27 | return client.post(paintApi) { 28 | referer("paintBoard") 29 | body = params.asParams 30 | }.receive() 31 | } catch (e: ClientRequestException) { 32 | throw IllegalStateException(json(e.response.strData())["data"]?.asString) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /core/src/test/kotlin/login-script.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.features.cookies.cookies 2 | import org.hoshino9.luogu.LuoGu 3 | import org.hoshino9.luogu.LuoGuClient 4 | import org.hoshino9.luogu.test.BaseTest 5 | import org.hoshino9.luogu.test.BaseTest.Companion.config 6 | import org.hoshino9.luogu.test.BaseTest.Companion.verifyPath 7 | import org.hoshino9.luogu.user.currentUser 8 | import java.nio.file.Files 9 | import java.util.Scanner 10 | 11 | object LuoGuTest : BaseTest() 12 | 13 | suspend fun main() { 14 | LuoGuTest.run { 15 | login() 16 | println("logged in: ${user.uid}") 17 | saveCookie() 18 | println("save cookie") 19 | } 20 | } 21 | 22 | suspend fun LuoGuTest.login() { 23 | Files.newOutputStream(verifyPath.also { path -> 24 | path.toFile().let { 25 | if (it.exists().not()) { 26 | it.parentFile.mkdirs() 27 | it.createNewFile() 28 | } 29 | } 30 | }).write(client.verifyCode()) 31 | 32 | println("Please input verify code") 33 | val verifyCode: String = Scanner(System.`in`).next() 34 | client.login(LuoGuClient.LoginForm(config.getProperty("account"), config.getProperty("password"), verifyCode)) 35 | user = client.currentUser !!.user 36 | } -------------------------------------------------------------------------------- /extensions/contest/test/kotlin/ContestTest.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.features.ClientRequestException 2 | import io.ktor.util.toByteArray 3 | import kotlinx.coroutines.runBlocking 4 | import org.hoshino9.luogu.contest.ContestListPage 5 | import org.hoshino9.luogu.contest.contestListPage 6 | import org.hoshino9.luogu.contest.contestPage 7 | import org.hoshino9.luogu.contest.joinContest 8 | import org.hoshino9.luogu.test.BaseTest 9 | import org.hoshino9.luogu.test.printAllMember 10 | import org.hoshino9.luogu.utils.strData 11 | import org.junit.Test 12 | import kotlin.reflect.full.memberProperties 13 | 14 | class ContestTest : BaseTest() { 15 | @Test 16 | fun contestList() { 17 | luogu.contestListPage().run { 18 | printAllMember() 19 | } 20 | } 21 | 22 | @Test 23 | fun contestInfo() { 24 | luogu.contestListPage().contests.first().id.let { 25 | luogu.contestPage(it).run { 26 | printAllMember() 27 | } 28 | } 29 | } 30 | 31 | // @Test 32 | // fun contestJoin() { 33 | // runBlocking { 34 | // try { 35 | // luogu.joinContest(24975, "123") 36 | // } catch(e: ClientRequestException) { 37 | // e.response.strData().run(::println) 38 | // } 39 | // } 40 | // } 41 | } -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/Color.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | data class Color(val r: Int, val g: Int, val b: Int) 4 | 5 | val colors = listOf( 6 | Color(0, 0, 0), 7 | Color(255, 255, 255), 8 | Color(170, 170, 170), 9 | Color(85, 85, 85), 10 | Color(254, 211, 199), 11 | Color(255, 196, 206), 12 | Color(250, 172, 142), 13 | Color(255, 139, 131), 14 | Color(244, 67, 54), 15 | Color(233, 30, 99), 16 | Color(226, 102, 158), 17 | Color(156, 39, 176), 18 | Color(103, 58, 183), 19 | Color(63, 81, 181), 20 | Color(0, 70, 112), 21 | Color(5, 113, 151), 22 | Color(33, 150, 243), 23 | Color(0, 188, 212), 24 | Color(59, 229, 219), 25 | Color(151, 253, 220), 26 | Color(22, 115, 0), 27 | Color(55, 169, 60), 28 | Color(137, 230, 66), 29 | Color(215, 255, 7), 30 | Color(255, 246, 209), 31 | Color(248, 203, 140), 32 | Color(255, 235, 59), 33 | Color(255, 193, 7), 34 | Color(255, 152, 0), 35 | Color(255, 87, 34), 36 | Color(184, 63, 39), 37 | Color(121, 85, 72) 38 | ) 39 | 40 | val Color.toRGB: Int 41 | get() { 42 | return (r.toString(16) + g.toString(16) + b.toString(16)).toInt(16) 43 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/Page.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | import com.google.gson.JsonObject 4 | import kotlinx.coroutines.runBlocking 5 | import org.hoshino9.luogu.LuoGuClient 6 | import org.hoshino9.luogu.utils.parseJson 7 | 8 | interface Page { 9 | val currentData: JsonObject 10 | suspend fun load(): JsonObject 11 | } 12 | 13 | interface MutablePage : Page { 14 | suspend fun refresh() 15 | } 16 | 17 | abstract class BasePage : Page { 18 | open val feInjection: JsonObject by lazy { runBlocking { load() } } 19 | 20 | override val currentData: JsonObject get() = feInjection["currentData"].asJsonObject 21 | } 22 | 23 | abstract class BaseMutablePage : MutablePage, BasePage() { 24 | private var _feInjection: JsonObject? = null 25 | 26 | override val feInjection: JsonObject 27 | get() { 28 | if (_feInjection == null) runBlocking { refresh() } 29 | return _feInjection !! 30 | } 31 | 32 | abstract val url: String 33 | abstract val client: LuoGuClient 34 | 35 | override suspend fun load(): JsonObject { 36 | return String(client.get(url)).parseJson().asJsonObject 37 | } 38 | 39 | override suspend fun refresh() { 40 | _feInjection = load() 41 | } 42 | } -------------------------------------------------------------------------------- /extensions/contest/main/kotlin/org/hoshino9/luogu/contest/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("ContestUtils") 2 | 3 | package org.hoshino9.luogu.contest 4 | 5 | import com.google.gson.JsonObject 6 | import io.ktor.client.call.receive 7 | import io.ktor.client.features.ClientRequestException 8 | import org.hoshino9.luogu.LuoGu 9 | import org.hoshino9.luogu.utils.apiPost 10 | import org.hoshino9.luogu.utils.asParams 11 | import org.hoshino9.luogu.utils.referer 12 | 13 | /** 14 | * 获取比赛列表页面 15 | * 16 | * @param page 页码 17 | */ 18 | fun LuoGu.contestListPage(page: Int = 1): ContestListPage { 19 | return ContestListPage(page, client) 20 | } 21 | 22 | /** 23 | * 获取比赛详细页面 24 | * 25 | * @param id 比赛 ID 26 | */ 27 | fun LuoGu.contestPage(id: Int): ContestPage { 28 | return ContestPage(id, client) 29 | } 30 | 31 | /** 32 | * 加入比赛 33 | * 34 | * @param id 比赛 ID 35 | * @param code 邀请码(邀请赛需要) 36 | * 37 | * @throws ClientRequestException 38 | */ 39 | suspend fun LuoGu.joinContest(id: Int, code: String? = null) { 40 | apiPost("fe/api/contest/join/$id") { 41 | referer("contest/$id") 42 | 43 | code?.let { 44 | body = JsonObject().apply { 45 | addProperty("code", it) 46 | }.asParams 47 | } 48 | }.receive() 49 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/page/DeprecatedLuoGuPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.page 2 | 3 | import io.ktor.client.call.receive 4 | import io.ktor.client.request.request 5 | import io.ktor.client.response.HttpResponse 6 | import kotlinx.atomicfu.update 7 | import kotlinx.coroutines.runBlocking 8 | import org.hoshino9.luogu.utils.HttpClient 9 | import org.hoshino9.luogu.utils.emptyClient 10 | import org.hoshino9.luogu.utils.json 11 | import org.jsoup.Jsoup 12 | import org.jsoup.nodes.Document 13 | import java.net.URLDecoder 14 | 15 | /** 16 | * 过时洛谷页面抽象类,在非必要情况下不应该使用。 17 | * 提供了 [refresh] 的默认实现。 18 | */ 19 | @Deprecated("Deprecated", ReplaceWith("AbstractLuoGuPage")) 20 | abstract class DeprecatedLuoGuPage(open val client: HttpClient = emptyClient) : BaseLuoGuPage() { 21 | companion object { 22 | private val regex = Regex("""window\._feInjection = JSON\.parse\(decodeURIComponent\("(.+?)"\)\);""") 23 | } 24 | 25 | open suspend fun page(): Document { 26 | return Jsoup.parse(client.request(url) {}.receive()) 27 | } 28 | 29 | override fun refresh() { 30 | runBlocking { 31 | _feInjection.update { 32 | json(URLDecoder.decode(regex.find(page().toString()) !!.groupValues[1], "UTF-8")) 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /extensions/contest/main/kotlin/org/hoshino9/luogu/contest/types.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.contest 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.annotations.JsonAdapter 7 | import java.lang.reflect.Type 8 | 9 | internal object RuleTypeSerializer : JsonDeserializer { 10 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): RuleType { 11 | return RuleType.values()[json.asInt] 12 | } 13 | } 14 | 15 | internal object VisibilityTypeSerializer : JsonDeserializer { 16 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): VisibilityType { 17 | return VisibilityType.values()[json.asInt] 18 | } 19 | } 20 | 21 | @JsonAdapter(RuleTypeSerializer::class) 22 | enum class RuleType { 23 | None, 24 | OI, 25 | ACM, 26 | LD, // 乐多 27 | IOI, 28 | CF, 29 | DS // 夺时 30 | } 31 | 32 | @JsonAdapter(VisibilityTypeSerializer::class) 33 | enum class VisibilityType { 34 | Banned, 35 | Official, 36 | OrganizationPublic, 37 | OrganizationInternal, 38 | PersonalPublic, 39 | PersonalInvite, 40 | OrganizationInvite, 41 | OrganizationPublicVerifying, 42 | PersonalPublicVerifying 43 | } -------------------------------------------------------------------------------- /extensions/contest/main/kotlin/org/hoshino9/luogu/contest/ContestPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.contest 2 | 3 | import org.hoshino9.luogu.baseUrl 4 | import org.hoshino9.luogu.page.AbstractLuoGuPage 5 | import org.hoshino9.luogu.utils.HttpClient 6 | import org.hoshino9.luogu.utils.delegate 7 | import org.hoshino9.luogu.utils.emptyClient 8 | 9 | class ContestPage(val id: Int, client: HttpClient = emptyClient) : AbstractLuoGuPage(client) { 10 | data class Problem(val id: String, val score: Int, val submitted: Boolean) 11 | 12 | override val url: String get() = "$baseUrl/contest/$id" 13 | 14 | private val data = feInjection["currentData"].asJsonObject 15 | private val delegate = data.delegate 16 | 17 | val contest: Contest 18 | get() { 19 | return ContestImpl(data["contest"].asJsonObject) 20 | } 21 | 22 | val accessLevel: Int by delegate 23 | val joined: Boolean by delegate 24 | 25 | val contestProblems: List? 26 | get() { 27 | return data["contestProblems"].let { problems -> 28 | if (problems.isJsonNull) null else { 29 | problems.asJsonArray.map { problem -> 30 | problem.asJsonObject.delegate.let { 31 | val score: Int by it 32 | val submitted: Boolean by it 33 | 34 | Problem(problem.asJsonObject["problem"].asJsonObject["pid"].asString, score, submitted) 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("ProblemUtils") 2 | 3 | package org.hoshino9.luogu.problem 4 | 5 | import com.google.gson.JsonObject 6 | import io.ktor.client.call.receive 7 | import org.hoshino9.luogu.LuoGu 8 | import org.hoshino9.luogu.LuoGuClient 9 | import org.hoshino9.luogu.baseUrl 10 | import org.hoshino9.luogu.utils.* 11 | 12 | fun BaseProblem.lift(client: LuoGuClient): Problem = run { 13 | if (this is Problem) this 14 | else ProblemPageBuilder(pid, client).build().problem 15 | } 16 | 17 | fun BaseProblem.liftToLogged(client: LuoGuClient): LoggedProblem = run { 18 | if (this is LoggedProblem) this 19 | else LoggedProblemPageBuilder(pid, client).build().problem 20 | } 21 | 22 | /** 23 | * 题目列表 24 | * @param page 页数, 默认为 **1** 25 | * @param filter 过滤器 26 | * @return 返回题目列表 27 | * 28 | * @see Problem 29 | * @see ProblemSearchConfig 30 | */ 31 | fun LuoGuClient.problemList(page: Int = 1, filter: ProblemSearchConfig = ProblemSearchConfig()): ProblemListPage { 32 | return ProblemListPageBuilder(page, filter, this).build() 33 | } 34 | 35 | internal suspend fun LuoGuClient.doMark(pid: String, mark: String) { 36 | post("$baseUrl/fe/api/problem/$mark", 37 | JsonObject().apply { addProperty("pid", pid) }) 38 | } 39 | 40 | /** 41 | * 收藏题目 42 | */ 43 | suspend fun LuoGuClient.mark(pid: String) = doMark(pid, "tasklistAdd") 44 | 45 | /** 46 | * 取消收藏题目 47 | */ 48 | suspend fun LuoGuClient.unmark(pid: String) = doMark(pid, "tasklistRemove") -------------------------------------------------------------------------------- /extensions/paste/main/kotlin/org/hoshino9/luogu/paste/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("PasteUtils") 2 | 3 | package org.hoshino9.luogu.paste 4 | 5 | import com.google.gson.JsonObject 6 | import io.ktor.client.call.receive 7 | import org.hoshino9.luogu.LuoGu 8 | import org.hoshino9.luogu.utils.apiPost 9 | import org.hoshino9.luogu.utils.asParams 10 | import org.hoshino9.luogu.utils.json 11 | import org.hoshino9.luogu.utils.referer 12 | 13 | 14 | /** 15 | * 剪切板 16 | * @param code `markdown` 代码 17 | * @param public 是否公开, 默认 **true** 18 | * @return 返回剪切板的代码 19 | */ 20 | @JvmOverloads 21 | suspend fun LuoGu.newPaste(code: String, public: Boolean = true): String { 22 | val json = JsonObject().apply { 23 | addProperty("data", code) 24 | addProperty("public", public) 25 | }.asParams 26 | 27 | return apiPost("paste/new") { 28 | referer("paste") 29 | body = json 30 | }.receive().run(::json)["id"].asString 31 | } 32 | 33 | suspend fun LuoGu.deletePaste(id: String) { 34 | apiPost("paste/delete/$id") { 35 | referer("paste/$id") 36 | }.receive() 37 | } 38 | 39 | suspend fun LuoGu.editPaste(id: String, data: String, public: Boolean) { 40 | val json = JsonObject().apply { 41 | addProperty("data", data) 42 | addProperty("id", id) 43 | addProperty("public", public) 44 | }.asParams 45 | 46 | apiPost("paste/edit/$id") { 47 | referer("paste/$id") 48 | body = json 49 | }.receive() 50 | } 51 | 52 | fun LuoGu.pasteList(page: Int = 1): PasteList { 53 | return PasteList(page, client) 54 | } -------------------------------------------------------------------------------- /extensions/photo/main/kotlin/org/hoshino9/luogu/photo/Photo.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.photo 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.annotations.JsonAdapter 7 | import com.google.gson.annotations.SerializedName 8 | import org.hoshino9.luogu.user.BaseUser 9 | import org.hoshino9.luogu.utils.Deserializable 10 | import java.lang.reflect.Type 11 | 12 | @JsonAdapter(PhotoImpl.Serializer::class) 13 | interface Photo { 14 | companion object; 15 | val id: String 16 | val size: Int 17 | val date: String 18 | val user: BaseUser 19 | val url: String 20 | } 21 | 22 | data class PhotoImpl( 23 | override val id: String, 24 | override val size: Int, 25 | @SerializedName("uploadTime") override val date: String, 26 | @SerializedName("delegate") override val user: BaseUser, 27 | override val url: String 28 | ) : Photo { 29 | companion object Serializer : Deserializable(Photo::class), JsonDeserializer { 30 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Photo { 31 | return context.deserialize(json, PhotoImpl::class.java) 32 | } 33 | } 34 | 35 | override fun equals(other: Any?): Boolean { 36 | if (this === other) return true 37 | if (other !is Photo) return false 38 | 39 | if (id != other.id) return false 40 | 41 | return true 42 | } 43 | 44 | override fun hashCode(): Int { 45 | return id.hashCode() 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/ProblemSearchConfig.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.problem 2 | 3 | import org.hoshino9.luogu.tag.LuoGuTag 4 | 5 | /** 6 | * **你谷** 题目列表搜索配置 7 | */ 8 | data class ProblemSearchConfig @JvmOverloads constructor( 9 | val keyword: String = "", 10 | val type: Type = Type.LuoGu, 11 | val difficulty: Difficulty? = null, // null 表示全部 12 | val ordering: Ordering? = null, 13 | val tags: Tags? = null 14 | ) { 15 | data class Tags(val algo: List, val from: List, val time: List, val area: List) { 16 | override fun toString(): String { 17 | return listOf(algo, from, time, area).filter { it.isNotEmpty() }.joinToString(separator = "|") { group -> 18 | group.joinToString(separator = ",") { it.id.toString() } 19 | } 20 | } 21 | } 22 | 23 | data class Ordering(val sortBy: SortMode, val ordBy: OrdMode) { 24 | enum class SortMode { 25 | PID, 26 | NAME, 27 | DIFFICULTY 28 | } 29 | 30 | enum class OrdMode { 31 | ASC, // 升序 32 | DESC // 降序 33 | } 34 | 35 | override fun toString(): String { 36 | return "orderBy=${sortBy.name.toLowerCase()}&order=${ordBy.name.toLowerCase()}" 37 | } 38 | } 39 | 40 | override fun toString() : String { 41 | return buildString { 42 | append("keyword", "=", keyword, "&") 43 | append("type", "=", type.id, "&") 44 | if (difficulty != null) append("difficulty", "=", Difficulty.values().indexOf(difficulty), "&") 45 | if (ordering != null) append(ordering, "&") 46 | if (tags != null) append(tags, "&") 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet 2 | 3 | fun ktor(module: String, version: String): String = "io.ktor:ktor-$module:$version" 4 | fun kotlinx(module: String, version: String) = "org.jetbrains.kotlinx:kotlinx-$module:$version" 5 | 6 | val ktorVersion: String by rootProject.extra 7 | val coroutinesVersion: String by rootProject.extra 8 | 9 | sourceSets { 10 | main.configure { 11 | withConvention(KotlinSourceSet::class) { 12 | kotlin.srcDirs("src/main/gen") 13 | } 14 | } 15 | } 16 | 17 | repositories { 18 | maven("https://dl.bintray.com/kotlin/ktor/") 19 | } 20 | 21 | dependencies { 22 | // kotlin 23 | api(kotlin("stdlib")) 24 | api(kotlin("reflect")) 25 | api(kotlin("script-runtime")) 26 | 27 | // kotlinx 28 | api(kotlinx("coroutines-core", coroutinesVersion)) 29 | 30 | // ktor 31 | api(ktor("client-core", ktorVersion)) 32 | api(ktor("client-core-jvm", ktorVersion)) 33 | api(ktor("client-okhttp", ktorVersion)) 34 | api(ktor("client-websockets", ktorVersion)) 35 | api(ktor("client-gson", ktorVersion)) 36 | 37 | // others 38 | api("org.jsoup", "jsoup", "1.11.3") // HTML parser 39 | api("ch.qos.logback:logback-classic:1.2.1") 40 | 41 | // testing 42 | testApi(kotlin("test-junit")) 43 | } 44 | 45 | val createTestConfig = task("createTestConfig") { 46 | doLast { 47 | file("src/main/gen/TestConfig.kt").apply { 48 | if (exists().not()) { 49 | parentFile.mkdirs() 50 | createNewFile() 51 | } 52 | }.writeText("const val rootPath = \"${project.rootDir.absolutePath.replace("\\", "\\\\")}\"") 53 | } 54 | } 55 | 56 | tasks["compileKotlin"].dependsOn(createTestConfig) -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/test/BaseTest.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.test 2 | 3 | import com.google.gson.JsonObject 4 | import kotlinx.coroutines.runBlocking 5 | import org.hoshino9.luogu.LuoGu 6 | import org.hoshino9.luogu.LuoGuClient 7 | import org.hoshino9.luogu.user.LoggedUser 8 | import org.hoshino9.luogu.user.currentUser 9 | import rootPath 10 | import java.nio.file.Files 11 | import java.nio.file.Paths 12 | import java.util.Properties 13 | 14 | abstract class BaseTest { 15 | companion object { 16 | val root = Paths.get(rootPath) 17 | val verifyPath by lazy { root.resolve("verify.png") } 18 | val configPath by lazy { root.resolve("user.properties") } 19 | 20 | /** 21 | * 请将测试的工作目录设置为项目的根目录 22 | */ 23 | val config by lazy { 24 | Properties().apply { 25 | load(configPath.toFile().inputStream()) 26 | } 27 | } 28 | } 29 | 30 | lateinit var luogu: LuoGu 31 | lateinit var user: LoggedUser 32 | 33 | lateinit var client: LuoGuClient 34 | 35 | init { 36 | runBlocking { 37 | loadCookie() 38 | } 39 | } 40 | 41 | suspend fun loadCookie() { 42 | val id: String? = config.getProperty("__client_id") 43 | val uid: String? = config.getProperty("_uid") 44 | 45 | if (id != null && uid != null) { 46 | luogu = LuoGu(id, uid.toInt()) 47 | client = LuoGuClient(id, uid.toInt()) 48 | user = client.currentUser !!.user 49 | } else { 50 | System.err.println("No logged in.") 51 | 52 | luogu = LuoGu() 53 | client = LuoGuClient() 54 | } 55 | } 56 | 57 | fun saveCookie() { 58 | config.setProperty("__client_id", client.cookieClientId) 59 | config.setProperty("_uid", client.cookieUid) 60 | config.store(Files.newOutputStream(configPath), null) 61 | } 62 | } -------------------------------------------------------------------------------- /extensions/training/main/kotlin/org/hoshino9/luogu/training/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("TrainingUtils") 2 | 3 | package org.hoshino9.luogu.training 4 | 5 | import io.ktor.client.call.receive 6 | import io.ktor.client.request.get 7 | import org.hoshino9.luogu.LuoGu 8 | import org.hoshino9.luogu.baseUrl 9 | import org.hoshino9.luogu.user.UserPage 10 | import org.hoshino9.luogu.utils.* 11 | 12 | /** 13 | * 提升 BaseTraining 到 TrainingInfo 14 | */ 15 | fun BaseTraining.lift(client: HttpClient = emptyClient): TrainingInfo { 16 | return if (this is TrainingInfo) this else TrainingInfoPage(id, client).info 17 | } 18 | 19 | /** 20 | * 官方题单 21 | */ 22 | fun LuoGu.officialTraining(page: Int = 1): TrainingListPage { 23 | return TrainingListPageBuilder(page, TrainingListPageBuilder.Type.Official, client).build() 24 | } 25 | 26 | /** 27 | * 用户题单 28 | */ 29 | fun LuoGu.publicTraining(page: Int = 1): TrainingListPage { 30 | return TrainingListPageBuilder(page, TrainingListPageBuilder.Type.Select, client).build() 31 | } 32 | 33 | /** 34 | * 题单内容页面 35 | */ 36 | fun LuoGu.training(id: Int): TrainingInfoPage { 37 | return TrainingInfoPage(id, client) 38 | } 39 | 40 | internal fun url(id: Int, mark: Boolean) = "api/training/${if (mark) "mark" else "unmark"}/$id" 41 | 42 | /** 43 | * 收藏题单 44 | */ 45 | suspend fun LuoGu.markTraining(id: Int): String { 46 | return apiPost(url(id, true)).receive() 47 | } 48 | 49 | /** 50 | * 取消收藏题单 51 | */ 52 | suspend fun LuoGu.unmarkTraining(id: Int): String { 53 | return apiPost(url(id, false)).receive() 54 | } 55 | 56 | suspend fun UserPage.trainingList(page: Int = 1): TrainingListPage { 57 | val url = "$baseUrl/fe/api/user/createdTrainings?page=$page" 58 | val data = json(String(client.get(url))) 59 | 60 | return TrainingListPageImpl(data) 61 | } -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/ProblemListPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.problem 2 | 3 | import com.google.gson.* 4 | import com.google.gson.annotations.JsonAdapter 5 | import org.hoshino9.luogu.LuoGuClient 6 | import org.hoshino9.luogu.baseUrl 7 | import org.hoshino9.luogu.page.* 8 | import org.hoshino9.luogu.utils.Deserializable 9 | import org.hoshino9.luogu.utils.HttpClient 10 | import org.hoshino9.luogu.utils.delegate 11 | import org.hoshino9.luogu.utils.emptyClient 12 | import java.lang.reflect.Type 13 | 14 | @JsonAdapter(ProblemListPageImpl.Serializer::class) 15 | interface ProblemListPage : ListPage { 16 | companion object; 17 | 18 | val result: List 19 | } 20 | 21 | data class ProblemListPageImpl(override val result: List, override val count: Int, override val perPage: Int) : ProblemListPage { 22 | companion object Serializer : Deserializable(ProblemListPage::class), JsonDeserializer { 23 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): ProblemListPage = run { 24 | val data = json.asJsonObject 25 | val problems = data["problems"].asJsonObject.delegate 26 | val result: JsonArray by problems 27 | val count: Int by problems 28 | val perPage: Int by problems 29 | val realResult: List = result.map { 30 | BaseProblemImpl(it) 31 | } 32 | 33 | ProblemListPageImpl(realResult, count, perPage) 34 | } 35 | } 36 | } 37 | 38 | class ProblemListPageBuilder(val page: Int, val filter: ProblemSearchConfig, client: LuoGuClient) : AbstractLuoGuClientPage(client), PageBuilder { 39 | override val url: String 40 | get() = "$baseUrl/problem/list?page=$page&$filter" 41 | 42 | override fun build(): ProblemListPage { 43 | return ProblemListPageImpl(currentData) 44 | } 45 | } -------------------------------------------------------------------------------- /extensions/paste/main/kotlin/org/hoshino9/luogu/paste/Paste.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paste 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.annotations.JsonAdapter 7 | import org.hoshino9.luogu.baseUrl 8 | import org.hoshino9.luogu.page.AbstractLuoGuPage 9 | import org.hoshino9.luogu.user.BaseUser 10 | import org.hoshino9.luogu.utils.Deserializable 11 | import org.hoshino9.luogu.utils.HttpClient 12 | import org.hoshino9.luogu.utils.emptyClient 13 | import java.lang.reflect.Type 14 | 15 | @JsonAdapter(PasteImpl.Serializer::class) 16 | interface Paste { 17 | companion object; 18 | val id: String 19 | val user: BaseUser 20 | val time: Long 21 | val data: String 22 | val public: Boolean 23 | } 24 | 25 | data class PasteImpl(override val id: String, override val user: BaseUser, override val time: Long, override val data: String, override val public: Boolean) : Paste { 26 | companion object Serializer : Deserializable(Paste::class), JsonDeserializer { 27 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Paste { 28 | return context.deserialize(json, PasteImpl::class.java) 29 | } 30 | } 31 | 32 | override fun equals(other: Any?): Boolean { 33 | if (this === other) return true 34 | if (other !is Paste) return false 35 | 36 | if (id != other.id) return false 37 | 38 | return true 39 | } 40 | 41 | override fun hashCode(): Int { 42 | return id.hashCode() 43 | } 44 | } 45 | 46 | open class PastePage(id: String, client: HttpClient = emptyClient) : AbstractLuoGuPage(client) { 47 | override val url: String = "$baseUrl/paste/$id" 48 | 49 | private val data get() = feInjection["currentData"].asJsonObject["paste"].asJsonObject 50 | 51 | val paste: Paste get() = PasteImpl(data) 52 | } 53 | -------------------------------------------------------------------------------- /extensions/training/main/kotlin/org/hoshino9/luogu/training/TrainingListPage.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.training 2 | 3 | import com.google.gson.JsonArray 4 | import com.google.gson.JsonDeserializationContext 5 | import com.google.gson.JsonElement 6 | import com.google.gson.annotations.JsonAdapter 7 | import io.ktor.client.request.get 8 | import org.hoshino9.luogu.baseUrl 9 | import org.hoshino9.luogu.page.AbstractLuoGuPage 10 | import org.hoshino9.luogu.page.ListPage 11 | import org.hoshino9.luogu.page.PageBuilder 12 | import org.hoshino9.luogu.page.currentData 13 | import org.hoshino9.luogu.utils.* 14 | import java.lang.reflect.Type 15 | 16 | @JsonAdapter(TrainingListPageImpl.Serializer::class) 17 | interface TrainingListPage : ListPage { 18 | val result: List 19 | } 20 | 21 | data class TrainingListPageImpl(override val result: List, override val count: Int, override val perPage: Int) : TrainingListPage { 22 | companion object Serializer : JsonDeserializable(TrainingListPage::class) { 23 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): TrainingListPage { 24 | val trainings = json.asJsonObject["trainings"].asJsonObject.delegate 25 | val perPage: Int by trainings 26 | val count: Int by trainings 27 | val result: JsonArray by trainings 28 | 29 | val realResult = result.map { 30 | BaseTrainingImpl(it) 31 | } 32 | 33 | return TrainingListPageImpl(realResult, count, perPage) 34 | } 35 | } 36 | } 37 | 38 | class TrainingListPageBuilder(val page: Int = 1, val type: Type, client: HttpClient = emptyClient) 39 | : AbstractLuoGuPage(client), PageBuilder { 40 | enum class Type { 41 | Official, 42 | Select, 43 | } 44 | 45 | override val url: String = "$baseUrl/training/list?type=${type.name.toLowerCase()}&page=$page" 46 | override fun build(): TrainingListPage { 47 | return TrainingListPageImpl(currentData) 48 | } 49 | } -------------------------------------------------------------------------------- /extensions/photo/main/kotlin/org/hoshino9/luogu/photo/PhotoContent.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package org.hoshino9.luogu.photo 4 | 5 | import io.ktor.http.ContentType 6 | import io.ktor.http.content.OutgoingContent 7 | import kotlinx.coroutines.io.ByteWriteChannel 8 | import kotlinx.coroutines.io.writeStringUtf8 9 | 10 | sealed class Part { 11 | abstract val name: String 12 | abstract val body: () -> ByteArray 13 | 14 | data class Pair(override val name: String, val value: String) : Part() { 15 | override val body: () -> ByteArray 16 | get() = { value.toByteArray() } 17 | } 18 | 19 | data class File(override val name: String, val file: java.io.File, val contentType: ContentType) : Part() { 20 | override val body: () -> ByteArray 21 | get() = { file.readBytes() } 22 | } 23 | } 24 | 25 | class PhotoContent(val parts: List) : OutgoingContent.WriteChannelContent() { 26 | private val boundary: String = "hoshinosekaiichibankawaii" 27 | private val separator: String = "\r\n--$boundary" 28 | 29 | override val contentType: ContentType? 30 | get() = ContentType.MultiPart.FormData.withParameter("boundary", boundary) 31 | 32 | override suspend fun writeTo(channel: ByteWriteChannel) { 33 | parts.forEachIndexed { i, it -> 34 | channel.writeStringUtf8(if (i == 0) separator.drop(2) else separator) 35 | channel.writeStringUtf8("\r\n") 36 | channel.writeStringUtf8("Content-Disposition: form-data; name=\"${it.name}\"") 37 | 38 | when (it) { 39 | is Part.Pair -> { 40 | channel.writeStringUtf8("\r\n\r\n") 41 | it.body().forEach { 42 | channel.writeByte(it) 43 | } 44 | } 45 | 46 | is Part.File -> { 47 | channel.writeStringUtf8("; filename=\"${it.file.name}\"\r\n") 48 | channel.writeStringUtf8("Content-Type: ${it.contentType.contentType}/${it.contentType.contentSubtype}\r\n") 49 | channel.writeStringUtf8("Content-Length: ${it.file.length()}\r\n") 50 | channel.writeStringUtf8("\r\n") 51 | 52 | it.body().forEach { 53 | channel.writeByte(it) 54 | } 55 | } 56 | } 57 | 58 | } 59 | 60 | channel.writeStringUtf8(separator) 61 | channel.writeStringUtf8("--") 62 | } 63 | } -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/BoardProvider.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | import io.ktor.client.features.websocket.ws 4 | import io.ktor.client.request.get 5 | import io.ktor.http.cio.websocket.Frame 6 | import io.ktor.http.cio.websocket.readText 7 | import io.ktor.http.cio.websocket.send 8 | import kotlinx.atomicfu.AtomicRef 9 | import kotlinx.atomicfu.atomic 10 | import kotlinx.coroutines.* 11 | import kotlinx.coroutines.channels.Channel 12 | import org.hoshino9.luogu.baseUrl 13 | import org.hoshino9.luogu.utils.delegate 14 | import org.hoshino9.luogu.utils.emptyClient 15 | import org.hoshino9.luogu.utils.json 16 | import java.io.PrintStream 17 | import kotlin.coroutines.CoroutineContext 18 | import kotlin.coroutines.EmptyCoroutineContext 19 | 20 | interface BoardProvider { 21 | suspend fun board(): Board 22 | } 23 | 24 | object DefaultBoardProvider : BoardProvider { 25 | override suspend fun board(): Board { 26 | val lines = emptyClient.use { it.get(boardApi).lines().dropLast(1) } 27 | val board = Board(400, 800) 28 | 29 | lines.forEachIndexed { x, line -> 30 | line.forEachIndexed { y, color -> 31 | val index = color.toString().toInt(32) 32 | board.board[x][y] = index 33 | } 34 | } 35 | 36 | return board 37 | } 38 | } 39 | 40 | class WebSocketBoardProvider(override val coroutineContext: CoroutineContext = EmptyCoroutineContext) : BoardProvider, CoroutineScope { 41 | companion object { 42 | const val message = """{ "type": "join_channel", "channel": "paintboard", "channel_param": "" }""" 43 | } 44 | 45 | private var paintBoard: AtomicRef = atomic(null) 46 | 47 | val job: Job 48 | 49 | init { 50 | job = launch { 51 | emptyClient.ws(boardWsApi) { 52 | send(message) 53 | 54 | for (frame in incoming) { 55 | if (frame is Frame.Text) { 56 | json(frame.readText()).delegate.let { 57 | val type: String by it 58 | 59 | if (type == "paintboard_update") { 60 | val x: Int by it 61 | val y: Int by it 62 | val color: Int by it 63 | 64 | val pos = Pos(x, y) 65 | 66 | paintBoard.value?.set(pos, color) 67 | } else if (type == "result") { 68 | paintBoard.value = DefaultBoardProvider.board() 69 | } 70 | 71 | Unit 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | override suspend fun board(): Board { 80 | while (paintBoard.value == null) { 81 | } 82 | 83 | return paintBoard.value !! 84 | } 85 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Join the chat at https://gitter.im/LuoGuAPI/Lobby](https://badges.gitter.im/LuoGuAPI/Lobby.svg)](https://gitter.im/LuoGuAPI/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | [![](https://jitpack.io/v/HoshinoTented/LuoGuAPI/month.svg)][jitpack] 3 | [![Jitpack](https://jitpack.io/v/HoshinoTented/LuoGuAPI.svg)][jitpack] 4 | [![](https://img.shields.io/bintray/v/hoshinotented/hoshino9/luoguapi.svg)](https://bintray.com/hoshinotented/hoshino9/luoguapi) 5 | 6 | # LuoGuAPI 7 | [**你谷**](https://www.luogu.org) 的api 8 | 9 | 感谢您谷,终于美化 API 了 10 | 11 | # CI 12 | CI |Status 13 | -------:|:--------- 14 | CircleCI|[![CircleCI](https://circleci.com/gh/HoshinoTented/LuoGuAPI.svg?style=svg)](https://circleci.com/gh/HoshinoTented/LuoGuAPI) 15 | AppVeyor|[![Build status](https://ci.appveyor.com/api/projects/status/l66p8yqgxgjl9jph?svg=true)](https://ci.appveyor.com/project/HoshinoTented/luoguapi) 16 | 17 | [jitpack]: https://jitpack.io/#HoshinoTented/LuoGuAPI 18 | 19 | # Usage 20 | 21 | First, add `https://dl.bintray.com/hoshinotented/hoshino9` to your maven repository list. 22 | 23 | Like this: 24 | 25 | ```groovy 26 | repositories { 27 | maven { url = 'https://dl.bintray.com/hoshinotented/hoshino9' } 28 | } 29 | ``` 30 | 31 | ## Gradle 32 | 33 | ```groovy 34 | compile 'org.hoshino9:luoguapi-[submodule name]:[version]' 35 | ``` 36 | 37 | ```kotlin 38 | compile("org.hoshino9:luoguapi-[module name]:[version]") 39 | ``` 40 | 41 | ## Maven 42 | 43 | ```xml 44 | 45 | org.hoshino9 46 | [submodule name] 47 | [versioon] 48 | pom 49 | 50 | ``` 51 | 52 | # Build 53 | 您需要准备一个 [JDK](https://oracle.com) 并配置正确的 `JAVA_HOME`。 54 | 55 | 在项目根目录下执行以下命令: 56 | ```bash 57 | ./gradlew shadowJar 58 | ./gradlew sourcesJar 59 | ``` 60 | 会在 `/build/libs` 下生成三个 `.jar` 文件 61 | 62 | 其中: 63 | * `--all.jar` 为本体(附带依赖) 64 | * `--sources.jar` 为源码 65 | 66 | # Stable API 67 | 洛谷已 **正式** 开放 API 的功能(未选中的代表 `LuoGuAPI` 还未支持): 68 | 69 | - [x] 登录 70 | - [ ] 注册 71 | - [x] 两步验证 72 | - [x] 题目列表 73 | - [x] 题目内容 74 | - [x] 提交代码 75 | - [x] 图床 76 | - [ ] 题目的操作 77 | - [x] 比赛列表 78 | - [x] 比赛内容 79 | - [ ] 比赛的操作 80 | - [x] 剪切板列表 81 | - [x] 剪切板内容 82 | - [x] 新建剪切板 83 | - [x] 删除剪切板 84 | - [x] 编辑剪切板 85 | 86 | 洛谷还 **未正式** 开放但已经在开发中的 API(未选中同上): 87 | 88 | - [ ] 博客的系列操作(不确定,可能已经正式开放) 89 | 90 | 无法在 API 列表中寻找到的功能: 91 | 92 | - [ ] 讨论版 93 | - [ ] ~~试炼场~~ -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/utils/json-utils.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.utils 2 | 3 | import com.google.gson.* 4 | import java.math.BigDecimal 5 | import java.math.BigInteger 6 | import kotlin.reflect.KClass 7 | import kotlin.reflect.KProperty 8 | 9 | class JsonDelegate(val original: JsonObject, val context: JsonDeserializationContext? = null) { 10 | /** 11 | * @throws NoSuchElementException will be thrown when the element is not exists 12 | */ 13 | operator fun getValue(thisRef: Any?, property: KProperty<*>): T { 14 | val obj: JsonElement = original[property.name] ?: throw NoSuchElementException(property.name) 15 | val returnType = property.returnType 16 | val type = returnType.classifier as KClass<*> 17 | 18 | if (obj.isJsonNull) { 19 | if (returnType.isMarkedNullable) return null as T else throw TypeCastException("${property.name}(null) cannot be cast to non-null type $type") 20 | } 21 | 22 | return when (type) { 23 | JsonObject::class -> obj.asJsonObject 24 | JsonArray::class -> obj.asJsonArray 25 | JsonPrimitive::class -> obj.asJsonPrimitive 26 | 27 | String::class -> obj.asString 28 | Boolean::class -> obj.asBoolean 29 | Char::class -> obj.asCharacter 30 | 31 | Byte::class -> obj.asByte 32 | Short::class -> obj.asShort 33 | Int::class -> obj.asInt 34 | Long::class -> obj.asLong 35 | Float::class -> obj.asFloat 36 | Double::class -> obj.asDouble 37 | 38 | BigInteger::class -> obj.asBigInteger 39 | BigDecimal::class -> obj.asBigDecimal 40 | 41 | else -> (context ?: throw IllegalArgumentException("Can not cast ${property.name} to $type")) 42 | .deserialize(obj, type.java) 43 | } as T 44 | } 45 | } 46 | 47 | abstract class Deserializable(private val `class`: KClass) { 48 | companion object { 49 | val gson = Gson() 50 | } 51 | 52 | @JvmName("fromJson") 53 | operator fun invoke(source: JsonElement): T { 54 | return gson.fromJson(source, `class`.java) 55 | } 56 | } 57 | 58 | abstract class JsonDeserializable(`class`: KClass) : Deserializable(`class`), JsonDeserializer 59 | 60 | val JsonObject.delegate: JsonDelegate get() = delegateWith(null) 61 | fun JsonObject.delegateWith(context: JsonDeserializationContext?) = JsonDelegate(this, context) 62 | 63 | fun JsonElement.ifNull(): JsonElement? { 64 | return takeIf { it !is JsonNull } 65 | } 66 | 67 | fun json(content: String): JsonObject { 68 | return content.parseJson().asJsonObject 69 | } 70 | 71 | fun String.parseJson(): JsonElement { 72 | return JsonParser().parse(this) 73 | } -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/Solution.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.problem 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonElement 5 | import com.google.gson.annotations.JsonAdapter 6 | import org.hoshino9.luogu.LuoGuClient 7 | import org.hoshino9.luogu.page.AbstractLuoGuClientPage 8 | import org.hoshino9.luogu.page.ListPage 9 | import org.hoshino9.luogu.page.PageBuilder 10 | import org.hoshino9.luogu.user.BaseUser 11 | import org.hoshino9.luogu.utils.JsonDeserializable 12 | 13 | 14 | @JsonAdapter(SolutionImpl.Serializer::class) 15 | interface Solution { 16 | val author: BaseUser 17 | val commentCount: Int 18 | val content: String 19 | val contentDescription: String 20 | val currentUserVoteType: Int 21 | val id: Int 22 | val identifier: String 23 | val postTime: Int 24 | val status: Int 25 | val thumbUp: Int 26 | val title: String 27 | val type: String 28 | } 29 | 30 | data class SolutionImpl(override val author: BaseUser, override val commentCount: Int, override val content: String, override val contentDescription: String, override val currentUserVoteType: Int, override val id: Int, override val identifier: String, override val postTime: Int, override val status: Int, override val thumbUp: Int, override val title: String, override val type: String) : Solution { 31 | companion object Serializer : JsonDeserializable(Solution::class) { 32 | override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Solution { 33 | return context.deserialize(json, SolutionImpl::class.java) 34 | } 35 | } 36 | } 37 | 38 | @JsonAdapter(SolutionListPageImpl.Serializer::class) 39 | interface SolutionListPage : ListPage { 40 | val result: List 41 | } 42 | 43 | data class SolutionListPageImpl(override val result: List, override val count: Int, override val perPage: Int) : SolutionListPage { 44 | companion object Serializer : JsonDeserializable(SolutionListPage::class) { 45 | override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): SolutionListPage { 46 | return context.deserialize(json.asJsonObject["solutions"], SolutionListPageImpl::class.java) 47 | } 48 | } 49 | } 50 | 51 | // TODO: 题解需要登录才能获取,这里加个判断之类的 52 | class SolutionPageBuilder(val pid: String, client: LuoGuClient) : AbstractLuoGuClientPage(client), PageBuilder { 53 | override val url: String = "https://www.luogu.com.cn/problem/solution/$pid" 54 | 55 | override fun build(): SolutionListPage { 56 | return SolutionListPageImpl(currentData) 57 | } 58 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/user/LoggedUser.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.user 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.JsonObject 7 | import com.google.gson.annotations.JsonAdapter 8 | import io.ktor.client.call.receive 9 | import org.hoshino9.luogu.LuoGu 10 | import org.hoshino9.luogu.LuoGuClient 11 | import org.hoshino9.luogu.baseUrl 12 | import org.hoshino9.luogu.utils.* 13 | import java.lang.reflect.Type 14 | 15 | interface BaseLoggedUser : BaseUser { 16 | companion object; 17 | 18 | } 19 | 20 | @JsonAdapter(LoggedUserImpl.Serializer::class) 21 | interface LoggedUser : BaseLoggedUser, User { 22 | companion object; 23 | 24 | } 25 | 26 | /** 27 | * **你谷**用户类 28 | */ 29 | data class LoggedUserImpl(val user: User) : User by user, LoggedUser { 30 | companion object Serializer : Deserializable(LoggedUser::class), JsonDeserializer { 31 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): LoggedUser { 32 | val user = context.deserialize(json, User::class.java) 33 | 34 | return LoggedUserImpl(user) 35 | } 36 | } 37 | 38 | override fun equals(other: Any?): Boolean { 39 | return (other as? LoggedUserImpl)?.user == user 40 | } 41 | 42 | override fun hashCode(): Int { 43 | return user.hashCode() 44 | } 45 | } 46 | 47 | open class LoggedUserPage(uid: Int, client: LuoGuClient) : UserPage(uid, client) { 48 | override val user: LoggedUser by lazy { 49 | LoggedUserImpl(userObj) 50 | } 51 | } 52 | 53 | /** 54 | * 关注用户 55 | * 56 | * @param userId 目标用户 ID 57 | * @param isFollow true 为关注,false 为取关 58 | */ 59 | suspend fun LuoGu.doFollow(userId: Int, isFollow: Boolean = true) { 60 | JsonObject().apply { 61 | addProperty("uid", userId) 62 | addProperty("relationship", if (isFollow) 1 else 0) 63 | }.let { param -> 64 | apiPost("fe/api/user/updateRelationShip") { 65 | referer("user/$uid#following") 66 | body = param.asParams 67 | }.receive() 68 | } 69 | } 70 | 71 | suspend fun LuoGuClient.doFollow(userId: Int, isFollow: Boolean = true) { 72 | val params = JsonObject().apply { 73 | addProperty("uid", userId) 74 | addProperty("relationship", if (isFollow) 1 else 0) 75 | } 76 | 77 | post("$baseUrl/fe/api/user/updateRelationShip", params) 78 | } 79 | 80 | suspend fun LuoGu.follow(userId: Int) = doFollow(userId, true) 81 | suspend fun LuoGu.unfollow(userId: Int) = doFollow(userId, false) 82 | 83 | suspend fun LuoGuClient.follow(userId: Int) = doFollow(userId, true) 84 | suspend fun LuoGuClient.unfollow(userId: Int) = doFollow(userId, false) -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/utils/ktor-utils.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.utils 2 | 3 | import com.google.gson.JsonObject 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.HttpClientConfig 6 | import io.ktor.client.features.cookies.AcceptAllCookiesStorage 7 | import io.ktor.client.features.cookies.HttpCookies 8 | import io.ktor.client.features.json.GsonSerializer 9 | import io.ktor.client.features.json.JsonFeature 10 | import io.ktor.client.features.websocket.WebSockets 11 | import io.ktor.client.request.HttpRequestBuilder 12 | import io.ktor.client.request.request 13 | import io.ktor.client.response.HttpResponse 14 | import io.ktor.content.TextContent 15 | import io.ktor.http.* 16 | import io.ktor.util.toByteArray 17 | import kotlinx.coroutines.runBlocking 18 | import org.hoshino9.luogu.IllegalStatusCodeException 19 | import org.hoshino9.luogu.LuoGu 20 | import org.hoshino9.luogu.baseUrl 21 | 22 | val JsonObject.asParams: TextContent 23 | get() { 24 | return TextContent(toString(), ContentType.Application.Json) 25 | } 26 | 27 | fun HttpClientConfig<*>.emptyClientConfig() { 28 | install(WebSockets) 29 | } 30 | 31 | fun HttpClientConfig<*>.defaultClientConfig(cookiesConfig: HttpCookies.Config.() -> Unit) { 32 | emptyClientConfig() 33 | 34 | install(HttpCookies) { 35 | cookiesConfig() 36 | } 37 | 38 | install(JsonFeature) { 39 | serializer = GsonSerializer() 40 | } 41 | } 42 | 43 | val emptyClient = HttpClient { emptyClientConfig() } 44 | val defaultClient 45 | get() = HttpClient { 46 | defaultClientConfig { 47 | storage = AcceptAllCookiesStorage() 48 | } 49 | } 50 | 51 | fun specifiedCookieClient(cookies: List>): HttpClient { 52 | return HttpClient { 53 | defaultClientConfig { 54 | storage = AcceptAllCookiesStorage().apply { 55 | cookies.forEach { (url, cookie) -> 56 | runBlocking { 57 | addCookie(url, cookie) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | suspend inline fun HttpClient.apiGet(url: String, block: HttpRequestBuilder.() -> Unit = {}): HttpResponse { 66 | return request(url) { 67 | headers.append("x-luogu-type", "content-only") 68 | block() 69 | } 70 | } 71 | 72 | suspend inline fun LuoGu.apiPost(url: String, block: HttpRequestBuilder.() -> Unit = {}): HttpResponse { 73 | return client.request("$baseUrl/$url") { 74 | method = HttpMethod.Post 75 | headers.append("x-csrf-token", csrfToken()) 76 | block() 77 | } 78 | } 79 | 80 | fun HttpRequestBuilder.referer(ref: String) { 81 | headers.append("referer", "$baseUrl/$ref") 82 | } 83 | 84 | suspend fun HttpResponse.byteData(): ByteArray { 85 | return content.toByteArray() 86 | } 87 | 88 | suspend fun HttpResponse.strData(): String { 89 | return String(byteData()) 90 | } 91 | 92 | val ByteArray.asString: String get() = String(this) -------------------------------------------------------------------------------- /extensions/training/main/kotlin/org/hoshino9/luogu/training/TrainingForm.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.training 2 | 3 | import com.google.gson.JsonArray 4 | import com.google.gson.JsonNull 5 | import com.google.gson.JsonObject 6 | import io.ktor.client.call.receive 7 | import org.hoshino9.luogu.LuoGu 8 | import org.hoshino9.luogu.user.ProblemID 9 | import org.hoshino9.luogu.utils.* 10 | 11 | sealed class TrainingForm(val title: String, val description: String, val type: Int) { 12 | class PersonalPublic(title: String, description: String) : TrainingForm(title, description, 5) 13 | class PersonalPrivate(title: String, description: String) : TrainingForm(title, description, 8) 14 | 15 | sealed class GroupForm(title: String, description: String, type: Int, val teamID: Int) : TrainingForm(title, description, type) { 16 | class GroupPublic(title: String, description: String, teamID: Int) : GroupForm(title, description, 7, teamID) 17 | class GroupPrivate(title: String, description: String, teamID: Int) : GroupForm(title, description, 2, teamID) 18 | /** 19 | * @param deadline **秒级** 时间戳 20 | */ 21 | class GroupHomework(title: String, description: String, teamID: Int, val deadline: Long) : GroupForm(title, description, 4, teamID) { 22 | override fun asJson(): JsonObject { 23 | val sup = super.asJson() 24 | sup.getAsJsonObject("settings").addProperty("deadline", deadline) 25 | return sup 26 | } 27 | } 28 | 29 | override fun asJson(): JsonObject { 30 | return super.asJson().apply { 31 | addProperty("providerID", teamID) 32 | } 33 | } 34 | } 35 | 36 | 37 | open fun asJson(): JsonObject = run { 38 | JsonObject().apply { 39 | add("settings", JsonObject().apply { 40 | addProperty("title", title) 41 | addProperty("description", description) 42 | addProperty("type", type) 43 | }) 44 | } 45 | } 46 | } 47 | 48 | suspend fun LuoGu.newTraining(form: TrainingForm): Int = run { 49 | apiPost("api/training/new") { 50 | this.body = form.asJson().asParams 51 | referer("") 52 | }.receive() 53 | .run(::json) 54 | .get("id").asInt 55 | } 56 | 57 | suspend fun LuoGu.editTraining(id: Int, form: TrainingForm): Int = run { 58 | apiPost("api/training/editProblems/$id") { 59 | this.body = form.asJson().asParams 60 | referer("") 61 | }.receive() 62 | .run(::json) 63 | .get("id").asInt 64 | } 65 | 66 | suspend fun LuoGu.deleteTraining(id: Int) { 67 | apiPost("api/training/delete/$id") { 68 | referer("") 69 | }.receive() 70 | } 71 | 72 | suspend fun LuoGu.editTrainingProblems(id: Int, problems: List) { 73 | apiPost("api/training/editProblems/$id") { 74 | body = JsonObject().apply { 75 | add("pids", JsonArray().apply { 76 | problems.forEach { 77 | add(it) 78 | } 79 | }) 80 | }.asParams 81 | 82 | referer("") 83 | }.receive() 84 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/user/FollowList.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.user 2 | 3 | import com.google.gson.* 4 | import com.google.gson.annotations.JsonAdapter 5 | import io.ktor.client.request.get 6 | import kotlinx.coroutines.runBlocking 7 | import org.hoshino9.luogu.LuoGuClient 8 | import org.hoshino9.luogu.baseUrl 9 | import org.hoshino9.luogu.utils.* 10 | import java.lang.reflect.Type 11 | 12 | @JsonAdapter(FollowListUserImpl.Serializer::class) 13 | interface FollowListUser : BaseUser { 14 | companion object; 15 | val blogAddress: String? 16 | val followingCount: Int 17 | val followerCount: Int 18 | val ranking: Int? 19 | val userRelationship: Int 20 | val reverseUserRelationship: Int 21 | val passedProblemCount: Int 22 | val submittedProblemCount: Int 23 | } 24 | 25 | data class FollowListUserImpl(override val blogAddress: String?, override val followingCount: Int, override val followerCount: Int, override val ranking: Int?, override val userRelationship: Int, override val reverseUserRelationship: Int, override val passedProblemCount: Int, override val submittedProblemCount: Int, val baseUser: BaseUser) : BaseUser by baseUser, FollowListUser { 26 | companion object Serializer : Deserializable(FollowListUser::class), JsonDeserializer { 27 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): FollowListUser { 28 | val source = json.asJsonObject 29 | val delegate = source.delegate 30 | 31 | val blogAddress: String? by delegate 32 | val followingCount: Int by delegate 33 | val followerCount: Int by delegate 34 | val ranking: Int? by delegate 35 | val userRelationship: Int by delegate 36 | val reverseUserRelationship: Int by delegate 37 | val passedProblemCount: Int by delegate 38 | val submittedProblemCount: Int by delegate 39 | val baseUser: BaseUser = context.deserialize(json, BaseUser::class.java) 40 | 41 | return FollowListUserImpl(blogAddress, followingCount, followerCount, ranking, userRelationship, reverseUserRelationship, passedProblemCount, submittedProblemCount, baseUser) 42 | } 43 | } 44 | } 45 | 46 | @JsonAdapter(FollowListImpl.Serializer::class) 47 | interface FollowList { 48 | companion object; 49 | 50 | enum class Type { 51 | Followings, 52 | Followers 53 | } 54 | 55 | val result: List 56 | val count: Int 57 | } 58 | 59 | data class FollowListImpl(override val result: List, override val count: Int) : FollowList { 60 | companion object Serializer : Deserializable(FollowList::class), JsonDeserializer { 61 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext?): FollowList { 62 | val users = json.asJsonObject 63 | val usersDlgt = users.delegate 64 | val count: Int by usersDlgt 65 | val result: JsonArray by usersDlgt 66 | val list = result.map { 67 | FollowListUserImpl(it) 68 | } 69 | 70 | return FollowListImpl(list, count) 71 | } 72 | } 73 | } 74 | 75 | suspend operator fun FollowList.Companion.invoke(uid: Int, page: Int, type: FollowList.Type, client: LuoGuClient): FollowList { 76 | val url = "$baseUrl/fe/api/user/${type.name.toLowerCase()}?user=$uid&page=$page" 77 | val data = json(String(client.get(url))).getAsJsonObject("users") 78 | 79 | return FollowListImpl(data) 80 | } -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/LoggedProblem.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.problem 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.JsonObject 7 | import com.google.gson.annotations.JsonAdapter 8 | import org.hoshino9.luogu.LuoGuClient 9 | import org.hoshino9.luogu.page.currentData 10 | import org.hoshino9.luogu.utils.Deserializable 11 | import org.hoshino9.luogu.utils.delegate 12 | import java.lang.reflect.Type 13 | 14 | interface IBaseLoggedBaseProblem : BaseProblem { 15 | companion object; 16 | val accepted: Boolean 17 | } 18 | 19 | @JsonAdapter(LoggedBaseProblemImpl.Serializer::class) 20 | interface LoggedBaseProblem : IBaseLoggedBaseProblem { 21 | companion object; 22 | val submitted: Boolean 23 | } 24 | 25 | data class LoggedBaseProblemImpl(override val accepted: Boolean, override val submitted: Boolean, private val baseProblem: BaseProblem) : BaseProblem by baseProblem, LoggedBaseProblem { 26 | companion object Serializer : Deserializable(LoggedBaseProblem::class), JsonDeserializer { 27 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): LoggedBaseProblem { 28 | val source = json.asJsonObject 29 | val delegate = source.delegate 30 | 31 | val accepted: Boolean by delegate 32 | val submitted: Boolean by delegate 33 | val baseProblem: BaseProblem = context.deserialize(json, BaseProblem::class.java) 34 | 35 | return LoggedBaseProblemImpl(accepted, submitted, baseProblem) 36 | } 37 | } 38 | } 39 | 40 | @JsonAdapter(LoggedProblemImpl.Serializer::class) 41 | interface LoggedProblem : IBaseLoggedBaseProblem, Problem { 42 | companion object; 43 | val score: Int 44 | val showScore: Boolean 45 | } 46 | 47 | data class LoggedProblemImpl(override val accepted: Boolean, override val score: Int, override val showScore: Boolean, private val problem: Problem) : Problem by problem, LoggedProblem { 48 | companion object Serializer : Deserializable(LoggedProblem::class), JsonDeserializer { 49 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): LoggedProblem { 50 | val source = json.asJsonObject 51 | val delegate = source.delegate 52 | 53 | val accepted: Boolean by delegate 54 | val score: Int by delegate 55 | val showScore: Boolean by delegate 56 | val problem: Problem = context.deserialize(json, Problem::class.java) 57 | 58 | return LoggedProblemImpl(accepted, score, showScore, problem) 59 | } 60 | } 61 | } 62 | 63 | interface LoggedProblemPage : ProblemPage { 64 | override val problem: LoggedProblem 65 | } 66 | 67 | data class LoggedProblemPageImpl(override val problem: LoggedProblem) : LoggedProblemPage { 68 | companion object Serializer : Deserializable(LoggedProblemPage::class), JsonDeserializer { 69 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): LoggedProblemPage = run { 70 | val data = json.asJsonObject.delegate 71 | val problem: JsonObject by data 72 | val solutions: SolutionListPage by data 73 | 74 | LoggedProblemPageImpl(LoggedProblemImpl(problem)) 75 | } 76 | } 77 | } 78 | 79 | class LoggedProblemPageBuilder(pid: String, client: LuoGuClient) : ProblemPageBuilder(pid, client) { 80 | override fun build(): LoggedProblemPage = run { 81 | LoggedProblemPageImpl(currentData) 82 | } 83 | } -------------------------------------------------------------------------------- /extensions/paintboard/main/kotlin/org/hoshino9/luogu/paintboard/PainterManager.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.paintboard 2 | 3 | import kotlinx.coroutines.* 4 | import java.util.LinkedList 5 | import java.util.Queue 6 | import kotlin.coroutines.CoroutineContext 7 | import kotlin.coroutines.EmptyCoroutineContext 8 | 9 | /** 10 | * 计时器 11 | * 12 | * 每经过 [delay] 毫秒,就会向请求队列 [queue] 中添加计时器 13 | * 14 | * @param painter 当前计时器对应的绘画者 15 | * @param queue 目标请求队列 16 | * @param scope 协程域 17 | * @param delay 延时,单位为毫秒 18 | */ 19 | data class Timer(val painter: Painter, private val queue: Queue, override val coroutineContext: CoroutineContext = EmptyCoroutineContext, val delay: Long) : CoroutineScope { 20 | private lateinit var timer: Job 21 | 22 | init { 23 | resetTimer() 24 | } 25 | 26 | fun resetTimer() { 27 | timer = launch { 28 | delay(this@Timer.delay) 29 | 30 | queue.add(this@Timer) 31 | 32 | println("${painter.id} is ready.") 33 | } 34 | } 35 | 36 | fun canPaint(): Boolean { 37 | return timer.isCompleted 38 | } 39 | } 40 | 41 | /** 42 | * 绘画者管理器 43 | * 44 | * @param photoProvider 提供绘画的坐标和颜色 45 | * @param begin 开始绘画的坐标 46 | * @param coroutineContext 协程上下文 47 | * @param boardProvider 提供全局绘板 48 | */ 49 | class PainterManager(val photoProvider: PhotoProvider, val begin: Pos, override val coroutineContext: CoroutineContext = EmptyCoroutineContext, val boardProvider: BoardProvider) : CoroutineScope { 50 | private val internalTimers: MutableList = LinkedList() 51 | private val internalRequestQueue: Queue = LinkedList() 52 | 53 | var job: Job? = null 54 | private set 55 | 56 | val timers: List get() = internalTimers 57 | val requestQueue: Collection get() = internalRequestQueue 58 | 59 | /** 60 | * 开始进行绘画 61 | * 62 | * 同一时间,同一 PainterManager 只能有同一个绘画线程 63 | */ 64 | @Synchronized 65 | fun paint() { 66 | val job = this.job 67 | 68 | if (job != null && job.isActive) throw IllegalStateException("Job is working.") 69 | 70 | this.job = launch { 71 | loop@ while (isActive) { 72 | if (internalRequestQueue.isNotEmpty()) { 73 | val (pos, color) = photoProvider.current() 74 | val currentPos = Pos(begin.x + pos.x, begin.y + pos.y) 75 | 76 | if (color == null) { 77 | println("Skip empty color: $currentPos(offset: $pos)") 78 | photoProvider.next() 79 | continue 80 | } 81 | 82 | if (boardProvider.board()[currentPos] == color) { 83 | println("Skip same color: $currentPos(offset: $pos)") 84 | photoProvider.next() 85 | continue 86 | } 87 | 88 | val current = internalRequestQueue.remove() 89 | 90 | try { 91 | println("${current.painter.id} is painting: $currentPos(offset: $pos) with color: $color") 92 | 93 | val result = current.painter.paint(currentPos, color) 94 | photoProvider.next() 95 | 96 | println("${current.painter.id} is painted: $result") 97 | } catch (e: IllegalStateException) { 98 | println("${current.painter.id} paint failed: ${e.message}") 99 | 100 | when (e.message) { 101 | "没有登录" -> { 102 | println("removed no login: ${current.painter.id}") 103 | internalTimers.remove(current) 104 | 105 | continue@loop 106 | } 107 | } 108 | } 109 | 110 | current.resetTimer() 111 | } 112 | } 113 | } 114 | } 115 | 116 | fun add(painter: Painter, delay: Long) { 117 | internalTimers.add(Timer(painter, internalRequestQueue, coroutineContext, delay)) 118 | } 119 | } -------------------------------------------------------------------------------- /extensions/contest/main/kotlin/org/hoshino9/luogu/contest/Contest.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.contest 2 | 3 | import com.google.gson.JsonDeserializationContext 4 | import com.google.gson.JsonDeserializer 5 | import com.google.gson.JsonElement 6 | import com.google.gson.annotations.JsonAdapter 7 | import org.hoshino9.luogu.user.BaseUser 8 | import org.hoshino9.luogu.utils.Deserializable 9 | import org.hoshino9.luogu.utils.delegate 10 | import java.lang.reflect.Type 11 | 12 | @JsonAdapter(Host.Serializer::class) 13 | sealed class Host { 14 | companion object Serializer : JsonDeserializer { 15 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Host { 16 | val source = json.asJsonObject 17 | val host: Host = source.let { host -> 18 | if (host.has("id")) Organization(host["id"].asInt, host["name"].asString) else { 19 | User(context.deserialize(host, BaseUser::class.java)) 20 | } 21 | } 22 | 23 | return host 24 | } 25 | } 26 | 27 | abstract val id: Int 28 | abstract val name: String 29 | 30 | data class User(val user: BaseUser) : Host() { 31 | override val id: Int 32 | get() = user.uid 33 | 34 | override val name: String 35 | get() = user.name 36 | } 37 | 38 | data class Organization(override val id: Int, override val name: String) : Host() 39 | } 40 | 41 | @JsonAdapter(BaseContestImpl.Serializer::class) 42 | interface BaseContest { 43 | companion object; 44 | 45 | /** 46 | * 比赛 ID 47 | */ 48 | val id: Int 49 | 50 | /** 51 | * 比赛名次 52 | */ 53 | val name: String 54 | 55 | /** 56 | * 比赛举办者 57 | */ 58 | val host: Host 59 | 60 | /** 61 | * 比赛题目数量 62 | */ 63 | val problemCount: Int 64 | 65 | /** 66 | * 是否 Rated 67 | */ 68 | val rated: Boolean 69 | 70 | /** 71 | * 比赛规则类型 72 | */ 73 | val ruleType: RuleType 74 | 75 | /** 76 | * 比赛类型(可见类型) 77 | */ 78 | val visibilityType: VisibilityType 79 | 80 | /** 81 | * 比赛开始时间(时间戳) 82 | */ 83 | val startTime: Long 84 | 85 | /** 86 | * 比赛结束时间(时间戳) 87 | */ 88 | val endTime: Long 89 | } 90 | 91 | data class BaseContestImpl(override val id: Int, override val name: String, override val host: Host, override val problemCount: Int, override val rated: Boolean, override val ruleType: RuleType, override val visibilityType: VisibilityType, override val startTime: Long, override val endTime: Long) : BaseContest { 92 | companion object Serializer : Deserializable(BaseContest::class), JsonDeserializer { 93 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): BaseContest { 94 | return context.deserialize(json, BaseContestImpl::class.java) 95 | } 96 | } 97 | 98 | override fun equals(other: Any?): Boolean { 99 | return (other as? BaseContest)?.id == this.id 100 | } 101 | 102 | override fun hashCode(): Int { 103 | return id.hashCode() 104 | } 105 | } 106 | 107 | @JsonAdapter(ContestImpl.Serializer::class) 108 | interface Contest : BaseContest { 109 | companion object; 110 | 111 | /** 112 | * 比赛介绍 113 | */ 114 | val description: String 115 | 116 | /** 117 | * 比赛总参加人数 118 | */ 119 | val totalParticipants: Int 120 | } 121 | 122 | data class ContestImpl(override val description: String, override val totalParticipants: Int, val baseContest: BaseContest) : BaseContest by baseContest, Contest { 123 | companion object Serializer : Deserializable(Contest::class), JsonDeserializer { 124 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Contest { 125 | val source = json.asJsonObject 126 | val delegate = source.delegate 127 | 128 | val description: String by delegate 129 | val totalParticipants: Int by delegate 130 | val baseContest: BaseContest = context.deserialize(json, BaseContest::class.java) 131 | 132 | return ContestImpl(description, totalParticipants, baseContest) 133 | } 134 | } 135 | 136 | override fun equals(other: Any?): Boolean { 137 | return baseContest == other 138 | } 139 | 140 | override fun hashCode(): Int { 141 | return baseContest.hashCode() 142 | } 143 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/user/User.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package org.hoshino9.luogu.user 4 | 5 | import com.google.gson.* 6 | import com.google.gson.annotations.JsonAdapter 7 | import org.hoshino9.luogu.LuoGuClient 8 | import org.hoshino9.luogu.baseUrl 9 | import org.hoshino9.luogu.page.AbstractLuoGuClientPage 10 | import org.hoshino9.luogu.page.AbstractLuoGuPage 11 | import org.hoshino9.luogu.page.currentData 12 | import org.hoshino9.luogu.team.BaseTeam 13 | import org.hoshino9.luogu.utils.* 14 | import org.hoshino9.luogu.utils.Deserializable.Companion.gson 15 | import java.lang.reflect.Type 16 | 17 | typealias ProblemID = String 18 | typealias UID = Int 19 | 20 | @JsonAdapter(BaseUserImpl.Serializer::class) 21 | interface BaseUser { 22 | companion object; 23 | /** 24 | * 用户 id 25 | */ 26 | val uid: Int 27 | 28 | /** 29 | * 用户名称 30 | */ 31 | val name: String 32 | 33 | /** 34 | * 用户等级(颜色) 35 | */ 36 | val color: String 37 | 38 | /** 39 | * 用户头衔 40 | */ 41 | val badge: String? 42 | 43 | /** 44 | * 用户签名 45 | */ 46 | val slogan: String 47 | 48 | /** 49 | * 用户 ccf 等级 50 | */ 51 | val ccfLevel: Int 52 | 53 | /** 54 | * 是否管理员 55 | */ 56 | val isAdmin: Boolean 57 | 58 | /** 59 | * 是否被封禁 60 | */ 61 | val isBanned: Boolean 62 | } 63 | 64 | data class BaseUserImpl(override val uid: Int, override val name: String, override val color: String, override val badge: String?, override val slogan: String, override val ccfLevel: Int, override val isAdmin: Boolean, override val isBanned: Boolean) : BaseUser { 65 | companion object Serializer : Deserializable(BaseUser::class), JsonDeserializer { 66 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): BaseUser { 67 | return context.deserialize(json, BaseUserImpl::class.java) 68 | } 69 | } 70 | 71 | override fun equals(other: Any?): Boolean { 72 | return (other as? BaseUserImpl)?.uid == uid 73 | } 74 | 75 | override fun hashCode(): Int { 76 | return uid.hashCode() 77 | } 78 | } 79 | 80 | @JsonAdapter(UserImpl.Serializer::class) 81 | interface User : BaseUser { 82 | companion object; 83 | val ranking: Int? 84 | val introduction: String 85 | } 86 | 87 | data class UserImpl(override val ranking: Int?, override val introduction: String, val baseUser: BaseUser) : BaseUser by baseUser, User { 88 | companion object Serializer : JsonDeserializable(User::class) { 89 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): User { 90 | val source = json.asJsonObject 91 | val delegate = source.delegate 92 | 93 | val ranking: Int? by delegate 94 | val introduction: String by delegate 95 | val baseUser = context.deserialize(json, BaseUser::class.java) 96 | 97 | return UserImpl(ranking, introduction, baseUser) 98 | } 99 | } 100 | 101 | override fun equals(other: Any?): Boolean { 102 | return baseUser == other 103 | } 104 | 105 | override fun hashCode(): Int { 106 | return baseUser.hashCode() 107 | } 108 | } 109 | 110 | open class UserPage(val uid: Int, client: LuoGuClient) : AbstractLuoGuClientPage(client) { 111 | data class Team(val team: BaseTeam, val permission: Int) 112 | 113 | override val url: String get() = "$baseUrl/user/$uid" 114 | 115 | protected val data = currentData 116 | protected val userObj: JsonObject = data["user"].asJsonObject 117 | 118 | open val user: User by lazy { 119 | UserImpl(userObj) 120 | } 121 | 122 | val teams: List by lazy { 123 | data["teams"].asJsonArray.map { 124 | gson.fromJson(it, Team::class.java) 125 | } 126 | } 127 | 128 | private fun problemList(attr: String): List { 129 | return data[attr].asJsonArray.map { 130 | it.asJsonObject["pid"].asString 131 | } 132 | } 133 | 134 | val passedProblems: List get() = problemList("passedProblems") 135 | val submittedProblems: List get() = problemList("submittedProblems") 136 | suspend fun followers(page: Int = 1): FollowList = FollowList(uid, page, FollowList.Type.Followers, client) 137 | suspend fun followings(page: Int = 1): FollowList = FollowList(uid, page, FollowList.Type.Followings, client) 138 | } -------------------------------------------------------------------------------- /extensions/training/main/kotlin/org/hoshino9/luogu/training/Training.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.training 2 | 3 | import com.google.gson.* 4 | import com.google.gson.annotations.JsonAdapter 5 | import org.hoshino9.luogu.problem.BaseProblem 6 | import org.hoshino9.luogu.problem.BaseProblemImpl 7 | import org.hoshino9.luogu.user.BaseUser 8 | import org.hoshino9.luogu.user.ProblemID 9 | import org.hoshino9.luogu.utils.Deserializable 10 | import org.hoshino9.luogu.utils.delegate 11 | import java.lang.reflect.Type 12 | 13 | @JsonAdapter(BaseTrainingImpl.Serializer::class) 14 | interface BaseTraining { 15 | companion object; 16 | 17 | /** 18 | * 创建时间 19 | */ 20 | val createTime: Long 21 | 22 | /** 23 | * 结束时间 24 | */ 25 | val deadline: Long? 26 | 27 | /** 28 | * 题单题目数量 29 | */ 30 | val problemCount: Int 31 | 32 | /** 33 | * 收藏数量 34 | */ 35 | val markCount: Int 36 | 37 | /** 38 | * 题单 ID 39 | */ 40 | val id: Int 41 | 42 | /** 43 | * 题单标题 44 | */ 45 | val title: String 46 | 47 | /** 48 | * 题单类型 49 | */ 50 | val type: Int 51 | 52 | /** 53 | * 题单提供者 54 | */ 55 | val provider: BaseUser 56 | } 57 | 58 | data class BaseTrainingImpl(override val createTime: Long, override val deadline: Long?, override val problemCount: Int, override val markCount: Int, override val id: Int, override val title: String, override val type: Int, override val provider: BaseUser) : BaseTraining { 59 | companion object Serializer : Deserializable(BaseTraining::class), JsonDeserializer { 60 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): BaseTraining { 61 | return context.deserialize(json, BaseTrainingImpl::class.java) 62 | } 63 | } 64 | } 65 | 66 | @JsonAdapter(UserScoreImpl.Serializer::class) 67 | interface UserScore { 68 | companion object; 69 | 70 | /** 71 | * 总分 72 | */ 73 | val totalScore: Int 74 | 75 | /** 76 | * 各个题目的得分 77 | */ 78 | val score: Map 79 | } 80 | 81 | data class UserScoreImpl(override val totalScore: Int, override val score: Map) : UserScore { 82 | companion object Serializer : Deserializable(UserScore::class), JsonDeserializer { 83 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): UserScore { 84 | val delegate = json.asJsonObject.delegate 85 | val totalScore: Int by delegate 86 | val score: JsonObject by delegate 87 | val scoreMap = score.keySet().map { 88 | val value = score[it] 89 | 90 | it to if (value.isJsonNull) null else value.asInt 91 | }.toMap() 92 | 93 | return UserScoreImpl(totalScore, scoreMap) 94 | } 95 | } 96 | } 97 | 98 | @JsonAdapter(TrainingInfoImpl.Serializer::class) 99 | interface TrainingInfo : BaseTraining { 100 | companion object; 101 | 102 | /** 103 | * 题单描述 104 | */ 105 | val description: String 106 | 107 | /** 108 | * 是否收藏 109 | */ 110 | val marked: Boolean 111 | 112 | /** 113 | * 题单内题目 114 | */ 115 | val problems: List 116 | 117 | /** 118 | * 用户分数 119 | */ 120 | val userScore: UserScore? 121 | } 122 | 123 | data class TrainingInfoImpl(override val description: String, override val marked: Boolean, override val problems: List, override val userScore: UserScore?, val base: BaseTraining) : TrainingInfo, BaseTraining by base { 124 | companion object Serializer : Deserializable(TrainingInfo::class), JsonDeserializer { 125 | override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): TrainingInfo { 126 | val training = json.asJsonObject.delegate 127 | val description: String by training 128 | val marked: Boolean by training 129 | val problems: JsonArray by training 130 | val base: BaseTraining = context.deserialize(json, BaseTraining::class.java) 131 | val userScore: UserScore? = context.deserialize(training.original["userScore"], UserScore::class.java) 132 | 133 | val processedProblems = problems.map { 134 | BaseProblemImpl(it.asJsonObject["problem"].asJsonObject) 135 | } 136 | 137 | return TrainingInfoImpl(description, marked, processedProblems, userScore, base) 138 | } 139 | } 140 | } -------------------------------------------------------------------------------- /extensions/photo/main/kotlin/org/hoshino9/luogu/photo/ext.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("PhotoUtils") 2 | 3 | package org.hoshino9.luogu.photo 4 | 5 | import com.google.gson.JsonArray 6 | import com.google.gson.JsonObject 7 | import io.ktor.client.call.receive 8 | import io.ktor.client.request.get 9 | import io.ktor.client.request.post 10 | import io.ktor.client.response.HttpResponse 11 | import io.ktor.http.ContentType 12 | import org.hoshino9.luogu.LuoGu 13 | import org.hoshino9.luogu.baseUrl 14 | import org.hoshino9.luogu.utils.* 15 | import java.io.File 16 | 17 | /** 18 | * 生成图片上传链接 19 | * @param watermark 水印类型,0 为无水印,1 为仅 Logo,2 为 Logo + 用户名 20 | * @param verifyCode 验证码 21 | * @return 返回一个 Json 对象 22 | */ 23 | private suspend fun LuoGu.generateUploadLink(watermark: Int = 1, verifyCode: String): JsonObject { 24 | return client.get("$baseUrl/api/image/generateUploadLink?watermarkType=$watermark&captcha=$verifyCode").run(::json) 25 | } 26 | 27 | /** 28 | * 上传图片 29 | * 30 | * @param watermark 水印 31 | * @param photo 图片的文件对象 32 | * @param verifyCode 验证码 33 | * @param contentType 请求的 Content-Type,需要根据图片的格式来进行选择 34 | * @return 返回图片 id 35 | * 36 | * @see [generateUploadLink] 37 | */ 38 | suspend fun LuoGu.pushPhoto(watermark: Int = 1, photo: File, verifyCode: String, contentType: ContentType): String { 39 | return generateUploadLink(watermark, verifyCode)["uploadLink"].asJsonObject.delegate.let { delegate -> 40 | val accessKeyID: String by delegate 41 | val callback: String by delegate 42 | val host: String by delegate 43 | val policy: String by delegate 44 | val signature: String by delegate 45 | 46 | // val body = MultipartBody.Builder() 47 | // .setType(MultipartBody.FORM) 48 | // .addFormDataPart("signature", signature) 49 | // .addFormDataPart("callback", callback) 50 | // .addFormDataPart("success_action_status", "200") 51 | // .addFormDataPart("OSSAccessKeyId", accessKeyID) 52 | // .addFormDataPart("policy", policy) 53 | // .addFormDataPart("key", "upload/image_hosting/__upload/\${filename}") 54 | // .addFormDataPart("name", photo.name) 55 | // .addFormDataPart("file", photo.name, RequestBody.create(MediaType.get(contentType.contentType + "/" + contentType.contentSubtype), photo)) 56 | // .build() 57 | 58 | val body = PhotoContent( 59 | listOf( 60 | Part.Pair("signature", signature), 61 | Part.Pair("callback", callback), 62 | Part.Pair("success_action_status", "200"), 63 | Part.Pair("OSSAccessKeyId", accessKeyID), 64 | Part.Pair("policy", policy), 65 | Part.Pair("key", "upload/image_hosting/__upload/\${filename}"), 66 | Part.Pair("name", photo.name), 67 | Part.File("file", photo, contentType) 68 | ) 69 | ) 70 | 71 | // client.toOkHttpClient().newCall( 72 | // Request.Builder() 73 | // .url(host) 74 | // .post(body) 75 | // .build() 76 | // ).execute().let { resp -> 77 | // if (! resp.isSuccessful) throw IllegalStateException() 78 | // 79 | // json(resp.body() !!.string()).run { 80 | // get("image").asJsonObject["id"].asString 81 | // } 82 | // } 83 | 84 | client.post(host) { 85 | referer("image") 86 | this.body = body 87 | }.let { 88 | json(it.strData()).run { 89 | get("image").asJsonObject["id"].asString 90 | } 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * 图床列表 97 | * @return 返回一个图片的列表 98 | * 99 | * @see Photo 100 | */ 101 | fun LuoGu.photoList(page: Int = 1): PhotoListPage { 102 | return PhotoListPage(page, client) 103 | } 104 | 105 | /** 106 | * 删除图片 107 | * @param photo 需要删除的图片 108 | */ 109 | suspend fun LuoGu.deletePhoto(photo: List) { 110 | val json = JsonObject().apply { 111 | add("images", JsonArray().apply { 112 | photo.forEach(::add) 113 | }) 114 | }.asParams 115 | 116 | apiPost("api/image/delete") { 117 | referer("image") 118 | body = json 119 | }.receive() 120 | } 121 | 122 | ///** 123 | // * 获取图床图片列表 124 | // * @param list 图片列表的元素 125 | // * @return 返回一个 Photo 列表 126 | // * 127 | // * @see Photo 128 | // */ 129 | //private fun getPhotos(list: Element): List { 130 | // return list.getElementsByClass("lg-table-row").map { 131 | // Photo.Factory(it).newInstance() 132 | // } 133 | //} 134 | 135 | 136 | /** 137 | * 上传图片到**你谷** 138 | * @param file 图片的 File 对象 139 | * @throws IllegalStatusCodeException 当 api 状态码不为 201 时抛出 140 | * @throws IllegalStatusCodeException 当 请求状态码不为 200 时抛出 141 | * 142 | * @see File 143 | */ 144 | //fun LoggedUser.postPhoto(file: File) { 145 | // luogu.executePost("app/upload", MultipartBody.Builder() 146 | // .setType(MultipartBody.FORM) 147 | // .addFormDataPart("picupload", file.name, file.asRequestBody("application/octet-stream".toMediaTypeOrNull())) 148 | // .build(), 149 | // referer("app/upload")) { resp -> 150 | // resp.assert() 151 | // val content = resp.strData 152 | // json(content) { 153 | // if (this["code"]?.asInt != 201) throw IllegalStatusCodeException(this["code"]) 154 | // } 155 | // } 156 | //} -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /extensions/problem/main/kotlin/org/hoshino9/luogu/problem/Problem.kt: -------------------------------------------------------------------------------- 1 | package org.hoshino9.luogu.problem 2 | 3 | import com.google.gson.* 4 | import com.google.gson.annotations.JsonAdapter 5 | import org.hoshino9.luogu.LuoGuClient 6 | import org.hoshino9.luogu.baseUrl 7 | import org.hoshino9.luogu.page.* 8 | import org.hoshino9.luogu.tag.IdLuoGuTag 9 | import org.hoshino9.luogu.tag.LuoGuTag 10 | import org.hoshino9.luogu.user.BaseUserImpl 11 | import org.hoshino9.luogu.user.BaseUser 12 | import org.hoshino9.luogu.utils.* 13 | 14 | typealias Tag = Int 15 | 16 | @JsonAdapter(BaseProblemImpl.Serializer::class) 17 | interface BaseProblem { 18 | companion object; 19 | /** 20 | * 难度 21 | */ 22 | val difficulty: Tag 23 | 24 | /** 25 | * 题目 id 26 | */ 27 | val pid: String 28 | 29 | /** 30 | * 题目标签 31 | */ 32 | val tags: List 33 | 34 | /** 35 | * 题目标题 36 | */ 37 | val title: String 38 | 39 | /** 40 | * 题目总通过量 41 | */ 42 | val totalAccepted: Long 43 | 44 | /** 45 | * 题目总提交量 46 | */ 47 | val totalSubmit: Long 48 | 49 | /** 50 | * 题目所在题库类型 51 | */ 52 | val type: Type 53 | 54 | /** 55 | * 题目是否需要翻译 56 | */ 57 | val wantsTranslation: Boolean 58 | 59 | /** 60 | * 题目满分 61 | */ 62 | val fullScore: Int 63 | } 64 | 65 | data class BaseProblemImpl(override val difficulty: Tag, override val pid: String, override val tags: List, override val title: String, override val totalAccepted: Long, override val totalSubmit: Long, override val type: Type, override val wantsTranslation: Boolean, override val fullScore: Int) : BaseProblem { 66 | companion object Serializer : Deserializable(BaseProblem::class), JsonDeserializer { 67 | override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): BaseProblem { 68 | fun parseTotal(elem: JsonElement): Long { 69 | elem as JsonPrimitive 70 | 71 | return when { 72 | elem.isString -> elem.asString.toLong() 73 | elem.isNumber -> elem.asLong 74 | else -> throw IllegalArgumentException(elem.toString()) 75 | } 76 | } 77 | 78 | val source = json.asJsonObject 79 | val delegate = source.delegate 80 | 81 | val pid: String by delegate 82 | val difficulty: Tag by delegate 83 | val title: String by delegate 84 | val tags: List = source["tags"].asJsonArray.map { 85 | it.asInt 86 | } 87 | 88 | val type: Type = Type.values().first { it.id == source["type"].asString } 89 | val totalAccepted: Long = source["totalAccepted"].run(::parseTotal) 90 | val totalSubmit: Long = source["totalSubmit"].run(::parseTotal) 91 | val wantsTranslation: Boolean by delegate 92 | val fullScore: Int by delegate 93 | 94 | return BaseProblemImpl(difficulty, pid, tags, title, totalAccepted, totalSubmit, type, wantsTranslation, fullScore) 95 | } 96 | } 97 | } 98 | 99 | @JsonAdapter(ProblemImpl.Serializer::class) 100 | interface Problem : BaseProblem { 101 | companion object; 102 | /** 103 | * 测试点限制 104 | */ 105 | data class Limit(val memory: Int, val time: Int) 106 | 107 | /** 108 | * 输入输出样例 109 | */ 110 | data class Sample(val input: String, val output: String) 111 | 112 | /** 113 | * 题目背景 114 | * markdown 代码 115 | */ 116 | val background: String 117 | 118 | /** 119 | * 是否可编辑 120 | */ 121 | val canEdit: Boolean 122 | 123 | /** 124 | * 题目描述 125 | * markdown 代码 126 | */ 127 | val description: String 128 | 129 | /** 130 | * 提示 131 | * markdown 代码 132 | */ 133 | val hint: String 134 | 135 | /** 136 | * 测试点限制 137 | */ 138 | val limits: List 139 | 140 | /** 141 | * 输入格式 142 | */ 143 | val inputFormat: String 144 | 145 | /** 146 | * 输出格式 147 | */ 148 | val outputFormat: String 149 | 150 | /** 151 | * 题目提供者 152 | */ 153 | val provider: BaseUser 154 | 155 | /** 156 | * 输入输出样例 157 | */ 158 | val samples: List 159 | } 160 | 161 | data class ProblemImpl(override val background: String, override val canEdit: Boolean, override val description: String, override val hint: String, override val limits: List, override val inputFormat: String, override val outputFormat: String, override val provider: BaseUser, override val samples: List, private val baseProblem: BaseProblem) : BaseProblem by baseProblem, Problem { 162 | companion object Serializer : Deserializable(Problem::class), JsonDeserializer { 163 | override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Problem { 164 | val source = json.asJsonObject 165 | val jsonDelegate = source.delegate 166 | 167 | val background: String by jsonDelegate 168 | val canEdit: Boolean by jsonDelegate 169 | val description: String by jsonDelegate 170 | val hint: String by jsonDelegate 171 | val inputFormat: String by jsonDelegate 172 | val outputFormat: String by jsonDelegate 173 | 174 | val limits: List = run { 175 | val json = source["limits"].asJsonObject 176 | val memory = json["memory"].asJsonArray 177 | val time = json["time"].asJsonArray 178 | 179 | (0 until memory.size()).map { 180 | Problem.Limit(memory[it].asInt, time[it].asInt) 181 | } 182 | } 183 | 184 | val delegate: BaseUser = BaseUserImpl(source["provider"].asJsonObject) 185 | val samples: List = source["samples"].asJsonArray.map { 186 | it.asJsonArray.let { 187 | val `in` = it[0].asString 188 | val out = it[1].asString 189 | 190 | Problem.Sample(`in`, out) 191 | } 192 | } 193 | 194 | val baseProblem: BaseProblem = context.deserialize(json, BaseProblem::class.java) 195 | 196 | return ProblemImpl(background, canEdit, description, hint, limits, inputFormat, outputFormat, delegate, samples, baseProblem) 197 | } 198 | } 199 | } 200 | 201 | @JsonAdapter(ProblemPageImpl.Serializer::class) 202 | interface ProblemPage { 203 | val problem: Problem 204 | } 205 | 206 | data class ProblemPageImpl(override val problem: Problem) : ProblemPage { 207 | companion object Serializer : JsonDeserializable(ProblemPage::class) { 208 | override fun deserialize(json: JsonElement, typeOfT: java.lang.reflect.Type, context: JsonDeserializationContext): ProblemPage = run { 209 | val data = json.asJsonObject.delegateWith(context) 210 | val problem: Problem by data 211 | 212 | ProblemPageImpl(problem) 213 | } 214 | } 215 | } 216 | 217 | open class ProblemPageBuilder(val pid: String, client: LuoGuClient) : AbstractLuoGuClientPage(client), PageBuilder { 218 | override val url: String get() = "$baseUrl/problem/$pid" 219 | 220 | override fun build(): ProblemPage = run { 221 | ProblemPageImpl(currentData) 222 | } 223 | } -------------------------------------------------------------------------------- /core/src/main/kotlin/org/hoshino9/luogu/LuoGu.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused", "UNUSED_PARAMETER") 2 | 3 | package org.hoshino9.luogu 4 | 5 | import com.google.gson.JsonNull 6 | import com.google.gson.JsonObject 7 | import io.ktor.client.call.receive 8 | import io.ktor.client.features.ClientRequestException 9 | import io.ktor.client.features.cookies.cookies 10 | import io.ktor.client.request.get 11 | import io.ktor.client.request.header 12 | import io.ktor.client.request.request 13 | import io.ktor.client.response.HttpResponse 14 | import io.ktor.client.response.readBytes 15 | import io.ktor.http.Cookie 16 | import io.ktor.http.HttpMethod 17 | import io.ktor.http.Url 18 | import io.ktor.http.isSuccess 19 | import kotlinx.atomicfu.update 20 | import kotlinx.coroutines.runBlocking 21 | import org.hoshino9.luogu.baseUrl 22 | import org.hoshino9.luogu.domain 23 | import org.hoshino9.luogu.page.BaseMutablePage 24 | import org.hoshino9.luogu.page.DeprecatedLuoGuPage 25 | import org.hoshino9.luogu.page.MutablePage 26 | import org.hoshino9.luogu.user.LoggedUserImpl 27 | import org.hoshino9.luogu.utils.* 28 | import org.jsoup.Jsoup 29 | import java.net.URLDecoder 30 | 31 | interface LuoGuClient { 32 | companion object { 33 | suspend operator fun invoke(): LuoGuClient { 34 | return Impl(defaultClient).apply { refresh() } 35 | } 36 | 37 | suspend operator fun invoke(clientId: String, uid: Int): LuoGuClient { 38 | val url = Url(baseUrl) 39 | 40 | return Impl(specifiedCookieClient( 41 | listOf( 42 | url to Cookie("_uid", uid.toString(), domain = domain), 43 | url to Cookie("__client_id", clientId, domain = domain) 44 | ) 45 | )).apply { refresh() } 46 | } 47 | 48 | private class Impl(val clientInternal: HttpClient) : LuoGuClient, BaseMutablePage() { 49 | override val client: LuoGuClient get() = this 50 | override val url: String get() = baseUrl 51 | 52 | private suspend fun csrfToken(): String { 53 | return csrfTokenFromPage(Jsoup.parse(String(client.get(baseUrl)))) 54 | } 55 | 56 | private suspend fun HttpResponse.assert(): ByteArray { 57 | return if (status.isSuccess()) { 58 | readBytes() 59 | } else throw IllegalStatusCodeException(status.value, strData()) 60 | } 61 | 62 | override suspend fun get(url: String): ByteArray { 63 | val resp = clientInternal.request(url) { 64 | header("x-luogu-type", "content-only") 65 | } 66 | 67 | return resp.assert() 68 | } 69 | 70 | override suspend fun post(url: String, body: JsonObject): ByteArray { 71 | val resp = clientInternal.request(url) { 72 | this.body = body.asParams 73 | method = HttpMethod.Post 74 | header("referer", "$baseUrl/") 75 | header("x-csrf-token", csrfToken()) 76 | } 77 | 78 | return resp.assert() 79 | } 80 | 81 | override val cookieUid: String? 82 | get() = runBlocking { 83 | clientInternal.cookies(baseUrl).firstOrNull { it.name == "_uid" }?.value 84 | } 85 | 86 | override val cookieClientId: String? 87 | get() = runBlocking { 88 | clientInternal.cookies(baseUrl).firstOrNull { it.name == "__client_id" }?.value 89 | } 90 | 91 | override suspend fun verifyCode(): ByteArray { 92 | return get("$baseUrl/api/verify/captcha") 93 | } 94 | 95 | override suspend fun login(form: LoginForm) { 96 | val body = Deserializable.gson.toJsonTree(form).asJsonObject 97 | 98 | post("$baseUrl/api/auth/userPassLogin", body) 99 | refresh() 100 | } 101 | 102 | override suspend fun logout(): Boolean { 103 | get("$baseUrl/api/auth/logout?uid=${cookieUid}") 104 | refresh() 105 | 106 | return true 107 | } 108 | 109 | override suspend fun load(): JsonObject { 110 | val regex = Regex("""window\._feInjection = JSON\.parse\(decodeURIComponent\("(.+?)"\)\);""") 111 | val page = String(client.get(url)) 112 | 113 | return json(URLDecoder.decode(regex.find(page) !!.groupValues[1], "UTF-8")) 114 | } 115 | } 116 | } 117 | 118 | data class LoginForm(val username: String, val password: String, val captcha: String) 119 | 120 | val cookieUid: String? 121 | val cookieClientId: String? 122 | 123 | suspend fun verifyCode(): ByteArray 124 | suspend fun login(form: LoginForm) 125 | suspend fun logout(): Boolean 126 | 127 | suspend fun get(url: String): ByteArray 128 | suspend fun post(url: String, body: JsonObject): ByteArray 129 | } 130 | 131 | /** 132 | * # LuoGu 133 | * **你谷**客户端类,目前仍然是 *过时页面*,等待洛谷官方更新。 134 | * 135 | * 洛谷代表了一个客户端,所有获取数据的函数都应该是 LuoGu 的扩展函数。 136 | * 137 | * ## 登陆 138 | * 139 | * [verifyCode] 用于获取验证码(验证码不仅仅用于登录), [login] 则用于登录,需要验证码。 140 | */ 141 | @Deprecated("Bad design", ReplaceWith("LuoGuClient")) 142 | @Suppress("MemberVisibilityCanBePrivate") 143 | class LuoGu constructor(client: HttpClient = defaultClient) : DeprecatedLuoGuPage(client) { 144 | companion object { 145 | @JvmName("fromCookie") 146 | operator fun invoke(clientId: String, uid: Int): LuoGu { 147 | val url = Url(baseUrl) 148 | 149 | return LuoGu( 150 | specifiedCookieClient( 151 | listOf( 152 | url to Cookie("_uid", uid.toString(), domain = domain), 153 | url to Cookie("__client_id", clientId, domain = domain) 154 | ) 155 | ) 156 | ) 157 | } 158 | } 159 | 160 | override val url: String = baseUrl 161 | 162 | init { 163 | runBlocking { 164 | refresh() 165 | } 166 | } 167 | 168 | val uid: Cookie 169 | get() { 170 | return runBlocking { 171 | client.cookies(baseUrl).first { 172 | it.name == "_uid" 173 | } 174 | } 175 | } 176 | 177 | val clientId: Cookie 178 | get() { 179 | return runBlocking { 180 | client.cookies(baseUrl).first { 181 | it.name == "__client_id" 182 | } 183 | } 184 | } 185 | 186 | /** 187 | * 一个奇怪的Token, 似乎十分重要, 大部分操作都需要这个 188 | */ 189 | suspend fun csrfToken(): String { 190 | return csrfTokenFromPage(Jsoup.parse(client.get(url))) 191 | } 192 | 193 | /** 194 | * 是否已登录 195 | */ 196 | val isLogged: Boolean 197 | get() { 198 | return feInjection["currentUser"] !is JsonNull 199 | } 200 | 201 | /** 202 | * 是否需要解锁 203 | * @return 返回解锁 mode (2fa 代表两步验证 secret 代表密码) 204 | */ 205 | val needUnlock: String? 206 | get() { 207 | return feInjection["currentData"].asJsonObject["mode"]?.asString 208 | } 209 | 210 | /** 211 | * 获取验证码 212 | */ 213 | suspend fun verifyCode(): ByteArray { 214 | return client.get("$baseUrl/api/verify/captcha") 215 | } 216 | 217 | /** 218 | * 解锁 219 | * 两步验证和密码解锁通用 220 | * @see needUnlock 221 | */ 222 | suspend fun unlock(code: String): String { 223 | val params = JsonObject().apply { addProperty("code", code) } 224 | 225 | return apiPost("api/auth/unlock") { 226 | referer("auth/unlock") 227 | body = params.asParams 228 | }.receive().also { 229 | refresh() 230 | } 231 | } 232 | 233 | /** 234 | * 登录**你谷** 235 | * 236 | * @param account 账号 237 | * @param password 密码 238 | * @param verifyCode 验证码, 通过 [LuoGu.verifyCode] 获得 239 | * 240 | * @throws ClientRequestException 241 | * 242 | * @see LuoGu.verifyCode 243 | * @see LoggedUserImpl 244 | */ 245 | suspend fun login(account: String, password: String, verifyCode: String) { 246 | val json = JsonObject().apply { 247 | addProperty("username", account) 248 | addProperty("password", password) 249 | addProperty("captcha", verifyCode) 250 | } 251 | 252 | apiPost("api/auth/userPassLogin") { 253 | referer("auth/login") 254 | body = json.asParams 255 | }.receive() 256 | 257 | refresh() 258 | } 259 | 260 | /** 261 | * 登出 262 | * 263 | * @throws ClientRequestException 264 | */ 265 | suspend fun logout() { 266 | client.get("$baseUrl/api/auth/logout?uid=${uid.value}") { 267 | referer("") 268 | } 269 | 270 | refresh() 271 | } 272 | } --------------------------------------------------------------------------------