Add GANMA! (#12206)

This commit is contained in:
kasperskier 2022-06-17 09:59:58 +08:00 committed by GitHub
parent b34822ade3
commit a9bd85d728
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 518 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

12
src/ja/ganma/build.gradle Normal file
View File

@ -0,0 +1,12 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'GANMA!'
pkgNameSuffix = 'ja.ganma'
extClass = '.GanmaFactory'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,126 @@
package eu.kanade.tachiyomi.extension.ja.ganma
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
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 kotlinx.serialization.json.decodeFromStream
import okhttp3.Request
import okhttp3.Response
import rx.Observable
open class Ganma : HttpSource(), ConfigurableSource {
override val id = sourceId
override val name = sourceName
override val lang = sourceLang
override val versionId = sourceVersionId
override val baseUrl = "https://ganma.jp"
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder().add("X-From", baseUrl)
override fun popularMangaRequest(page: Int) =
when (page) {
1 -> GET("$baseUrl/api/1.0/ranking", headers)
else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val list: List<Magazine> = response.parseAs()
return MangasPage(list.map { it.toSManga() }, false)
}
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/2.2/top", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val list = response.parseAs<Top>().boxes.flatMap { it.panels }
.filter { it.newestStoryItem != null }
.sortedByDescending { it.newestStoryItem!!.release }
return MangasPage(list.map { it.toSManga() }, false)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val pageNumber = when (filters.size) {
0 -> 1
else -> (filters[0] as TypeFilter).state + 1
}
return fetchPopularManga(pageNumber).map { mangasPage ->
MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, false)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
throw UnsupportedOperationException("Not used.")
override fun searchMangaParse(response: Response): MangasPage =
throw UnsupportedOperationException("Not used.")
// navigate Webview to web page
override fun mangaDetailsRequest(manga: SManga) =
GET("$baseUrl/${manga.url.alias()}", headers)
protected open fun realMangaDetailsRequest(manga: SManga) =
GET("$baseUrl/api/1.0/magazines/web/${manga.url.alias()}", headers)
override fun chapterListRequest(manga: SManga) = realMangaDetailsRequest(manga)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
.map { mangaDetailsParse(it).apply { initialized = true } }
override fun mangaDetailsParse(response: Response): SManga =
response.parseAs<Magazine>().toSMangaDetails()
protected open fun List<SChapter>.sortedDescending() = this.asReversed()
override fun chapterListParse(response: Response): List<SChapter> =
response.parseAs<Magazine>().getSChapterList().sortedDescending()
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
client.newCall(pageListRequest(chapter)).asObservable()
.map { pageListParse(chapter, it) }
override fun pageListRequest(chapter: SChapter) =
GET("$baseUrl/api/1.0/magazines/web/${chapter.url.alias()}", headers)
protected open fun pageListParse(chapter: SChapter, response: Response): List<Page> {
val manga: Magazine = response.parseAs()
val chapterId = chapter.url.substringAfter('/')
return manga.items.find { it.id == chapterId }!!.toPageList()
}
final override fun pageListParse(response: Response): List<Page> =
throw UnsupportedOperationException("Not used.")
override fun imageUrlParse(response: Response): String =
throw UnsupportedOperationException("Not used.")
protected open class TypeFilter : Filter.Select<String>("Type", arrayOf("Popular", "Completed"))
override fun getFilterList() = FilterList(TypeFilter())
protected inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromStream<Result<T>>(it.body!!.byteStream()).root
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = METADATA_PREF
title = "Metadata (Debug)"
setDefaultValue("")
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putString(METADATA_PREF, newValue as String).apply()
true
}
}.let { screen.addPreference(it) }
}
}

View File

@ -0,0 +1,135 @@
package eu.kanade.tachiyomi.extension.ja.ganma
import android.widget.Toast
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
class GanmaApp(private val metadata: Metadata) : Ganma() {
override val client = network.client.newBuilder()
.cookieJar(Cookies(metadata.baseUrl.toHttpUrl().host, metadata.cookieName))
.build()
private val appHeaders: Headers = Headers.Builder().apply {
add("User-Agent", metadata.userAgent)
add("X-From", metadata.baseUrl)
}.build()
override fun chapterListRequest(manga: SManga): Request {
checkSession()
return GET(metadata.baseUrl + String.format(metadata.magazineUrl, manga.url.mangaId()), appHeaders)
}
override fun List<SChapter>.sortedDescending() = this
override fun pageListRequest(chapter: SChapter): Request {
checkSession()
val (mangaId, chapterId) = chapter.url.chapterDir()
return GET(metadata.baseUrl + String.format(metadata.storyUrl, mangaId, chapterId), appHeaders)
}
override fun pageListParse(chapter: SChapter, response: Response): List<Page> =
try {
response.parseAs<AppStory>().toPageList()
} catch (e: Exception) {
throw Exception("Chapter not available!")
}
private fun checkSession() {
val expiration = preferences.getLong(SESSION_EXPIRATION_PREF, 0)
if (System.currentTimeMillis() + 60 * 1000 <= expiration) return // at least 1 minute
var field1 = preferences.getString(TOKEN_FIELD1_PREF, "")!!
var field2 = preferences.getString(TOKEN_FIELD2_PREF, "")!!
if (field1.isEmpty() || field2.isEmpty()) {
val response = client.newCall(POST(metadata.baseUrl + metadata.tokenUrl, appHeaders)).execute()
val token: JsonObject = response.parseAs()
field1 = token[metadata.tokenField1]!!.jsonPrimitive.content
field2 = token[metadata.tokenField2]!!.jsonPrimitive.content
}
val requestBody = FormBody.Builder().apply {
add(metadata.tokenField1, field1)
add(metadata.tokenField2, field2)
}.build()
val response = client.newCall(POST(metadata.baseUrl + metadata.sessionUrl, appHeaders, requestBody)).execute()
val session: Session = response.parseAs()
preferences.edit().apply {
putString(TOKEN_FIELD1_PREF, field1)
putString(TOKEN_FIELD2_PREF, field2)
putLong(SESSION_EXPIRATION_PREF, session.expire)
}.apply()
}
private fun clearSession(clearToken: Boolean) {
preferences.edit().apply {
putString(SESSION_PREF, "")
putLong(SESSION_EXPIRATION_PREF, 0)
if (clearToken) {
putString(TOKEN_FIELD1_PREF, "")
putString(TOKEN_FIELD2_PREF, "")
}
}.apply()
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
super.setupPreferenceScreen(screen)
SwitchPreferenceCompat(screen.context).apply {
title = "Clear session"
setOnPreferenceClickListener {
clearSession(clearToken = false)
Toast.makeText(screen.context, "Session cleared", Toast.LENGTH_SHORT).show()
false
}
}.let { screen.addPreference(it) }
SwitchPreferenceCompat(screen.context).apply {
title = "Clear token"
setOnPreferenceClickListener {
clearSession(clearToken = true)
Toast.makeText(screen.context, "Token cleared", Toast.LENGTH_SHORT).show()
false
}
}.let { screen.addPreference(it) }
}
class Cookies(private val host: String, private val name: String) : CookieJar {
override fun loadForRequest(url: HttpUrl): List<Cookie> {
if (url.host != host) return emptyList()
val cookie = Cookie.Builder().apply {
name(name)
value(preferences.getString(SESSION_PREF, "")!!)
domain(host)
}.build()
return listOf(cookie)
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (url.host != host) return
for (cookie in cookies) {
if (cookie.name == name) {
preferences.edit().putString(SESSION_PREF, cookie.value).apply()
}
}
}
}
companion object {
private const val TOKEN_FIELD1_PREF = "TOKEN_FIELD1"
private const val TOKEN_FIELD2_PREF = "TOKEN_FIELD2"
private const val SESSION_PREF = "SESSION"
private const val SESSION_EXPIRATION_PREF = "SESSION_EXPIRATION"
}
}

View File

@ -0,0 +1,195 @@
package eu.kanade.tachiyomi.extension.ja.ganma
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
@Serializable
data class Result<T>(val root: T)
// Manga
@Serializable
data class Magazine(
val id: String,
val alias: String? = null,
val title: String,
val description: String? = null,
val squareImage: File? = null,
// val squareWithLogoImage: File? = null,
val author: Author? = null,
val newestStoryItem: Story? = null,
val flags: Flags? = null,
val announcement: Announcement? = null,
val items: List<Story> = emptyList(),
) {
fun toSManga() = SManga.create().apply {
url = "${alias!!}#$id"
title = this@Magazine.title
thumbnail_url = squareImage!!.url
}
fun toSMangaDetails() = toSManga().apply {
author = this@Magazine.author?.penName
val flagsText = flags?.toText()
description = generateDescription(flagsText)
status = when {
flags?.isFinish == true -> SManga.COMPLETED
!flagsText.isNullOrEmpty() -> SManga.ONGOING
else -> SManga.UNKNOWN
}
}
private fun generateDescription(flagsText: String?): String {
val result = mutableListOf<String>()
if (!flagsText.isNullOrEmpty()) result.add("Updates: $flagsText")
if (announcement != null) result.add("Announcement: ${announcement.text}")
if (description != null) result.add(description)
return result.joinToString("\n\n")
}
fun getSChapterList() = items.map {
SChapter.create().apply {
url = "${alias!!}#$id/${it.id ?: it.storyId}"
val prefix = if (it.kind == "free") "" else "🔒 "
name = if (it.subtitle != null) "$prefix${it.title} ${it.subtitle}" else "$prefix${it.title}"
date_upload = it.releaseStart ?: -1
}
}
}
fun String.alias() = this.substringBefore('#')
fun String.mangaId() = this.substringAfter('#')
fun String.chapterDir(): Pair<String, String> =
with(this.substringAfter('#')) {
// this == [mangaId-UUID]/[chapterId-UUID]
Pair(substring(0, 36), substring(37, 37 + 36))
}
// Chapter
@Serializable
data class Story(
val id: String? = null,
val storyId: String? = null,
val title: String,
val subtitle: String? = null,
val release: Long = 0,
val releaseStart: Long? = null,
val page: Directory? = null,
val afterwordImage: File? = null,
val kind: String? = null,
) {
fun toPageList(): List<Page> {
val result = page!!.toPageList()
if (afterwordImage != null) {
result.add(Page(result.size, imageUrl = afterwordImage.url))
}
return result
}
}
@Serializable
data class File(val url: String)
@Serializable
data class Author(val penName: String? = null)
@Serializable
data class Top(val boxes: List<Box>)
@Serializable
data class Box(val panels: List<Magazine>)
@Serializable
data class Flags(
val isMonday: Boolean = false,
val isTuesday: Boolean = false,
val isWednesday: Boolean = false,
val isThursday: Boolean = false,
val isFriday: Boolean = false,
val isSaturday: Boolean = false,
val isSunday: Boolean = false,
val isWeekly: Boolean = false,
val isEveryOtherWeek: Boolean = false,
val isThreeConsecutiveWeeks: Boolean = false,
val isMonthly: Boolean = false,
val isFinish: Boolean = false,
// val isMGAward: Boolean = false,
// val isNew: Boolean = false,
) {
fun toText(): String {
val result = mutableListOf<String>()
val days = mutableListOf<String>()
arrayOf(isWeekly, isEveryOtherWeek, isThreeConsecutiveWeeks, isMonthly)
.forEachIndexed { i, value -> if (value) result.add(weekText[i]) }
arrayOf(isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday)
.forEachIndexed { i, value -> if (value) days.add(dayText[i] + "s") }
if (days.size == 7) {
result.add("every day")
} else if (days.size != 0) {
days[0] = "on " + days[0]
result += days
}
return result.joinToString(", ")
}
companion object {
private val weekText = arrayOf("every week", "every other week", "three weeks in a row", "every month")
private val dayText = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
}
}
@Serializable
data class Announcement(val text: String)
@Serializable
data class Directory(
val baseUrl: String,
val token: String,
val files: List<String>,
) {
fun toPageList(): MutableList<Page> =
files.mapIndexedTo(ArrayList(files.size + 1)) { i, file ->
Page(i, imageUrl = "$baseUrl$file?$token")
}
}
@Serializable
data class AppStory(val pages: List<AppPage>) {
fun toPageList(): List<Page> {
val result = ArrayList<Page>(pages.size)
pages.forEach {
if (it.imageURL != null)
result.add(Page(result.size, imageUrl = it.imageURL.url))
else if (it.afterwordImageURL != null)
result.add(Page(result.size, imageUrl = it.afterwordImageURL.url))
}
return result
}
}
@Serializable
data class AppPage(
val imageURL: File? = null,
val afterwordImageURL: File? = null,
)
// Please keep the data private to support the site,
// otherwise they might change their APIs.
@Serializable
data class Metadata(
val userAgent: String,
val baseUrl: String,
val tokenUrl: String,
val tokenField1: String,
val tokenField2: String,
val sessionUrl: String,
val cookieName: String,
val magazineUrl: String,
val storyUrl: String,
)
@Serializable
data class Session(val expire: Long)

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.extension.ja.ganma
import android.app.Application
import android.content.SharedPreferences
import android.util.Base64
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.MessageDigest
// source ID needed before class construction
// generated by running main() below
const val sourceId = 8045942616403978870
const val sourceName = "GANMA!"
const val sourceLang = "ja"
const val sourceVersionId = 1 // != extension version code
const val METADATA_PREF = "METADATA"
val json: Json = Injekt.get()
val preferences: SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$sourceId", 0x0000)
class GanmaFactory : SourceFactory {
override fun createSources(): List<Source> {
val source = try {
val metadata = preferences.getString(METADATA_PREF, "")!!
.also { if (it.isEmpty()) throw Exception() }
.let { Base64.decode(it.toByteArray(), Base64.DEFAULT) }
GanmaApp(json.decodeFromString(String(metadata)))
} catch (e: Exception) {
Ganma()
}
return listOf(source)
}
}
fun main() {
println(getSourceId()) // unfortunately there's no constexpr in Kotlin
}
fun getSourceId() = run { // copied from HttpSource
val key = "${sourceName.lowercase()}/$sourceLang/$sourceVersionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}