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