Add Tongli (#10263)
This commit is contained in:
parent
8fe8ca4fd1
commit
c6bad74c45
8
src/zh/tongli/build.gradle
Normal file
8
src/zh/tongli/build.gradle
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'Tongli'
|
||||||
|
extClass = '.Tongli'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
BIN
src/zh/tongli/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/zh/tongli/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
src/zh/tongli/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/zh/tongli/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
BIN
src/zh/tongli/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/zh/tongli/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.7 KiB |
BIN
src/zh/tongli/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/zh/tongli/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
src/zh/tongli/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/zh/tongli/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -0,0 +1,248 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.zh.tongli
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.text.InputType
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import keiyoushi.utils.getPreferences
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class Tongli : HttpSource(), ConfigurableSource {
|
||||||
|
override val name: String = "東立"
|
||||||
|
override val lang: String = "zh"
|
||||||
|
override val supportsLatest: Boolean = true
|
||||||
|
override val baseUrl: String = "https://ebook.tongli.com.tw"
|
||||||
|
private val apiUrl = "https://api.tongli.tw"
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences = getPreferences()
|
||||||
|
private val jsonMediaType = "application/json;charset=UTF-8".toMediaType()
|
||||||
|
|
||||||
|
// Popular
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$apiUrl/SellRanking/1", headers)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val mangas = response.parseAs<PopularResponseDto>().rankingSet[0].week.map {
|
||||||
|
it.toSManga()
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) =
|
||||||
|
GET("$apiUrl/SellShelf/6e7e5b75-1acd-4b7c-0097-08d6179fc10a/$page?pageSize=20", headers)
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val responseDto = response.parseAs<LatestResponseDto>()
|
||||||
|
val mangas = responseDto.books.map {
|
||||||
|
it.toSManga()
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, responseDto.totalPage > responseDto.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val requestBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("SearchStr", query)
|
||||||
|
.build()
|
||||||
|
return POST("$apiUrl/Search", headers, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
val mangas = response.parseAs<List<MangaDto>>().map {
|
||||||
|
it.toSManga()
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
val bookGroupID = manga.url.substringBefore(",")
|
||||||
|
val isSerial = manga.url.substringAfter(",")
|
||||||
|
return GET("$apiUrl/Book?bookGroupID=$bookGroupID&isSerial=$isSerial", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
return response.parseAs<DetailsDto>().toSManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapters
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
val newHeaders = headersBuilder().add("Authorization: Bearer ${getToken()}").build()
|
||||||
|
val bookGroupID = manga.url.substringBefore(",")
|
||||||
|
val isSerial = manga.url.substringAfter(",")
|
||||||
|
return GET("$apiUrl/Book/BookVol/$bookGroupID?bookID=null&isSerial=$isSerial", newHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
return response.parseAs<List<ChapterDto>>().mapNotNull {
|
||||||
|
it.toSChapter()
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
|
val bookGroupID = manga.url.substringBefore(",")
|
||||||
|
val isSerial = manga.url.substringAfter(",")
|
||||||
|
return "$baseUrl/book?id=$bookGroupID&isGroup=true&isSerials=$isSerial"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val newHeaders = headersBuilder().add("Authorization: Bearer ${getToken()}").build()
|
||||||
|
return GET("$apiUrl/Comic/sas/${chapter.url}", newHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
return response.parseAs<PageListResponseDto>().pages.mapIndexed { index, it ->
|
||||||
|
Page(index, imageUrl = it.imageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
private fun getToken(): String {
|
||||||
|
val token = preferences.getString("TOKEN", "")!!
|
||||||
|
val expires = preferences.getLong("EXPIRES", 0)
|
||||||
|
val currentTimeMillis = System.currentTimeMillis()
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
val email = preferences.getString("EMAIL", "")!!
|
||||||
|
val password = preferences.getString("PASSWORD", "")!!
|
||||||
|
if (email.isEmpty()) {
|
||||||
|
return loginAnonymous()
|
||||||
|
}
|
||||||
|
return login(email, password)
|
||||||
|
}
|
||||||
|
if (expires < currentTimeMillis) {
|
||||||
|
val refreshToken = preferences.getString("REFRESHTOKEN", "")!!
|
||||||
|
return refresh(refreshToken)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login(email: String, password: String): String {
|
||||||
|
val requestBody = buildJsonObject {
|
||||||
|
put("email", email)
|
||||||
|
put("password", password)
|
||||||
|
put("returnSecureToken", true)
|
||||||
|
}.toString().toRequestBody(jsonMediaType)
|
||||||
|
val response: TokenResponseDto
|
||||||
|
try {
|
||||||
|
response = client.newCall(
|
||||||
|
POST(
|
||||||
|
"https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=AIzaSyAJbYmo7KyhM_7CDXjjFXnp8bdRTNgbUIE",
|
||||||
|
headers,
|
||||||
|
requestBody,
|
||||||
|
),
|
||||||
|
).execute().parseAs<TokenResponseDto>()
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
// Remove email/password after failed login
|
||||||
|
preferences.edit()
|
||||||
|
.putString("EMAIL", "")
|
||||||
|
.putString("PASSWORD", "")
|
||||||
|
.apply()
|
||||||
|
throw Exception("登录失败")
|
||||||
|
}
|
||||||
|
val currentTimeMillis = System.currentTimeMillis()
|
||||||
|
preferences.edit()
|
||||||
|
.putString("TOKEN", response.idToken)
|
||||||
|
.putString("REFRESHTOKEN", response.refreshToken)
|
||||||
|
// Token expires after one hour
|
||||||
|
.putLong("EXPIRES", currentTimeMillis + 3600000)
|
||||||
|
.apply()
|
||||||
|
return response.idToken
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loginAnonymous(): String {
|
||||||
|
val requestBody = buildJsonObject {
|
||||||
|
put("returnSecureToken", true)
|
||||||
|
}.toString().toRequestBody(jsonMediaType)
|
||||||
|
val response = client.newCall(
|
||||||
|
POST(
|
||||||
|
"https://www.googleapis.com/identitytoolkit/v3/relyingparty/signupNewUser?key=AIzaSyAJbYmo7KyhM_7CDXjjFXnp8bdRTNgbUIE",
|
||||||
|
headers,
|
||||||
|
requestBody,
|
||||||
|
),
|
||||||
|
).execute().parseAs<TokenResponseDto>()
|
||||||
|
val currentTimeMillis = System.currentTimeMillis()
|
||||||
|
preferences.edit()
|
||||||
|
.putString("TOKEN", response.idToken)
|
||||||
|
.putString("REFRESHTOKEN", response.refreshToken)
|
||||||
|
.putLong("EXPIRES", currentTimeMillis + 3600000)
|
||||||
|
.apply()
|
||||||
|
return response.idToken
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh(refreshToken: String): String {
|
||||||
|
val requestBody = buildJsonObject {
|
||||||
|
put("grant_type", "refresh_token")
|
||||||
|
put("refresh_token", refreshToken)
|
||||||
|
}.toString().toRequestBody(jsonMediaType)
|
||||||
|
val response = client.newCall(
|
||||||
|
POST(
|
||||||
|
"https://securetoken.googleapis.com/v1/token?key=AIzaSyAJbYmo7KyhM_7CDXjjFXnp8bdRTNgbUIE",
|
||||||
|
headers,
|
||||||
|
requestBody,
|
||||||
|
),
|
||||||
|
).execute().parseAs<TokenResponseDto>()
|
||||||
|
val currentTimeMillis = System.currentTimeMillis()
|
||||||
|
preferences.edit()
|
||||||
|
.putString("TOKEN", response.idToken)
|
||||||
|
.putString("REFRESHTOKEN", response.refreshToken)
|
||||||
|
.putLong("EXPIRES", currentTimeMillis + 3600000)
|
||||||
|
.apply()
|
||||||
|
return response.idToken
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
ListPreference(screen.context).apply {
|
||||||
|
EditTextPreference(screen.context).apply {
|
||||||
|
key = "EMAIL"
|
||||||
|
title = "电子邮件"
|
||||||
|
summary = "该配置被修改后,会清空令牌(Token)以便重新登录;如果登录失败,会清空该配置"
|
||||||
|
setOnPreferenceChangeListener { _, _ ->
|
||||||
|
// clean token after email/password changed
|
||||||
|
preferences.edit().putString("TOKEN", "").apply()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}.let(screen::addPreference)
|
||||||
|
|
||||||
|
EditTextPreference(screen.context).apply {
|
||||||
|
key = "PASSWORD"
|
||||||
|
title = "密码"
|
||||||
|
summary = "该配置被修改后,会清空令牌(Token)以便重新登录;如果登录失败,会清空该配置"
|
||||||
|
setOnBindEditTextListener {
|
||||||
|
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
setOnPreferenceChangeListener { _, _ ->
|
||||||
|
// clean token after email/password changed
|
||||||
|
preferences.edit().putString("TOKEN", "").apply()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}.let(screen::addPreference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.zh.tongli
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PopularResponseDto(@SerialName("RankingSet") val rankingSet: List<RankingSetDto>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class RankingSetDto(@SerialName("Week") val week: List<MangaDto>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaDto(
|
||||||
|
@JsonNames("BookTitle", "Title") private val bookTitle: String,
|
||||||
|
@JsonNames("BookCoverURL", "CoverURL") private val bookCoverURL: String,
|
||||||
|
@SerialName("BookGroupID") private val bookGroupID: String,
|
||||||
|
@SerialName("IsSerial") private val isSerial: Boolean,
|
||||||
|
) {
|
||||||
|
fun toSManga() = SManga.create().apply {
|
||||||
|
url = "$bookGroupID,$isSerial"
|
||||||
|
title = bookTitle
|
||||||
|
thumbnail_url = bookCoverURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class LatestResponseDto(
|
||||||
|
@SerialName("TotalPage") val totalPage: Int,
|
||||||
|
@SerialName("Page") val page: Int,
|
||||||
|
@SerialName("Books") val books: List<MangaDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterDto(
|
||||||
|
@SerialName("BookID") private val bookID: String,
|
||||||
|
@SerialName("Vol") private val vol: String,
|
||||||
|
@SerialName("IsUpcoming") private val isUpcoming: Boolean,
|
||||||
|
@SerialName("IsPurchased") private val isPurchased: Boolean,
|
||||||
|
@SerialName("IsFree") private val isFree: Boolean,
|
||||||
|
) {
|
||||||
|
fun toSChapter(): SChapter? = SChapter.create().apply {
|
||||||
|
if (isUpcoming) return null
|
||||||
|
url = bookID
|
||||||
|
// Prepend lock emoji to name if not readable
|
||||||
|
name = if (isFree || isPurchased) vol else "\uD83D\uDD12 $vol"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class DetailsDto(
|
||||||
|
@SerialName("Title") private val title: String,
|
||||||
|
@SerialName("CoverURL") private val coverURL: String,
|
||||||
|
@SerialName("Authors") private val authors: List<AuthorDto>,
|
||||||
|
@SerialName("Introduction") private val introduction: String,
|
||||||
|
) {
|
||||||
|
fun toSManga() = SManga.create().apply {
|
||||||
|
title = this@DetailsDto.title
|
||||||
|
thumbnail_url = coverURL
|
||||||
|
author = authors.joinToString {
|
||||||
|
if (it.title.isNullOrEmpty()) it.name else "${it.title}:${it.name}"
|
||||||
|
}
|
||||||
|
description = introduction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class AuthorDto(@SerialName("Name") val name: String, @SerialName("Title") val title: String?)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PageListResponseDto(@SerialName("Pages") val pages: List<ImageDto>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ImageDto(@SerialName("ImageURL") val imageURL: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TokenResponseDto(
|
||||||
|
@JsonNames("id_token") val idToken: String,
|
||||||
|
@JsonNames("refresh_token") val refreshToken: String,
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user