Add Mehgazone and remove Latisbooks (#9040)

* Add initial version of mehgazone

* Update Mehgazone and remove Latisbooks

Latisbooks now redirects to Mehgazone

* Update Mehgazone.kt

* implement requested changes

* implement requested changes
This commit is contained in:
Ardit 2025-06-03 03:20:26 +01:00 committed by Draff
parent 30f9521ed0
commit 57cf47f154
Signed by: Draff
GPG Key ID: E8A89F3211677653
16 changed files with 412 additions and 154 deletions

View File

@ -1,8 +0,0 @@
ext {
extName = 'Latis Books'
extClass = '.Latisbooks'
extVersionCode = 6
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,146 +0,0 @@
package eu.kanade.tachiyomi.extension.en.latisbooks
import android.net.Uri.encode
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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 eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import java.util.Calendar
class Latisbooks : HttpSource() {
override val name = "Latis Books"
override val baseUrl = "https://www.latisbooks.com"
override val lang = "en"
override val supportsLatest = false
override val client: OkHttpClient = network.cloudflareClient
private val textToImageURL = "https://fakeimg.ryd.tools/1500x2126/ffffff/000000/?font=museo&font_size=42"
private fun String.image() = textToImageURL + "&text=" + encode(this)
private fun createManga(response: Response): SManga {
return SManga.create().apply {
initialized = true
title = "Bodysuit 23"
url = "/archive/"
thumbnail_url = "https://images.squarespace-cdn.com/content/v1/56595108e4b01110e1cf8735/1511856223610-NSB8O5OJ1F6KPQL0ZGBH/image-asset.jpeg"
}
}
// Popular
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.map { response ->
MangasPage(listOf(createManga(response)), false)
}
}
override fun popularMangaRequest(page: Int): Request {
return (GET("$baseUrl/archive/", headers))
}
override fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
// Latest
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
// Search
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Observable.just(MangasPage(emptyList(), false))
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException()
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
// Details
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
createManga(response)
}
}
override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException()
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
val cal: Calendar = Calendar.getInstance()
return response.asJsoup().select("ul.archive-item-list li a").map {
val date: List<String> = it.attr("abs:href").split("/")
cal.set(date[4].toInt(), date[5].toInt() - 1, date[6].toInt())
SChapter.create().apply {
name = it.text()
url = it.attr("abs:href")
date_upload = cal.timeInMillis
}
}
}
// Pages
// Adapted from the xkcd source's wordWrap function
private fun wordWrap(text: String) = buildString {
var charCount = 0
text.replace("\r\n", " ").split(' ').forEach { w ->
if (charCount > 25) {
append("\n")
charCount = 0
}
append(w).append(' ')
charCount += w.length + 1
}
}
override fun pageListRequest(chapter: SChapter): Request = GET(chapter.url, headers)
override fun pageListParse(response: Response): List<Page> {
val blocks = response.asJsoup().select("div.content-wrapper div.row div.col")
// Handle multiple images per page (e.g. Page 23+24)
val pages = blocks.select("div.image-block-wrapper img")
.mapIndexed { i, it -> Page(i, "", it.attr("abs:data-src")) }
.toMutableList()
val numImages = pages.size
// Add text above/below the image as xkcd-esque text pages after the image itself
pages.addAll(
blocks.select("div.html-block")
.map { it.select("div.sqs-block-content").first()!! }
// Some pages have empty html blocks (e.g. Page 1), so ignore them
.filter { it.childrenSize() > 0 }
.mapIndexed { i, it -> Page(i + numImages, "", wordWrap(it.text()).image()) }
.toList(),
)
return pages.toList()
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
override fun getFilterList() = FilterList()
}

View File

@ -0,0 +1,8 @@
ext {
extName = 'Mehgazone'
extClass = '.Mehgazone'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,330 @@
package eu.kanade.tachiyomi.extension.en.mehgazone
import android.content.SharedPreferences
import android.text.InputType
import android.text.SpannableString
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.util.Log
import android.view.ViewGroup
import android.widget.EditText
import android.widget.TextView
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.en.mehgazone.interceptors.BasicAuthInterceptor
import eu.kanade.tachiyomi.extension.en.mehgazone.serialization.ChapterListDto
import eu.kanade.tachiyomi.extension.en.mehgazone.serialization.PageListDto
import eu.kanade.tachiyomi.network.GET
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 eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.helper.Validate
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser.unescapeEntities
import org.jsoup.select.Collector
import org.jsoup.select.Elements
import org.jsoup.select.QueryParser
import rx.Observable
import java.text.SimpleDateFormat
import java.util.Locale
class Mehgazone : ConfigurableSource, HttpSource() {
override val name = "Mehgazone"
override val baseUrl = "https://mehgazone.com"
override val lang = "en"
override val supportsLatest = false
override val client: OkHttpClient by lazy {
network.cloudflareClient
.newBuilder()
.addInterceptor(authInterceptor)
.build()
}
private val uploadDateFormat: SimpleDateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
}
private val textToImageURL = "https://fakeimg.ryd.tools/1500x2126/ffffff/000000/?font=museo&font_size=42".toHttpUrl()
private fun String.image() = textToImageURL.newBuilder().setQueryParameter("text", this).build().toString()
private fun String.unescape() = unescapeEntities(this, false)
private fun String.linkify() = SpannableString(this).apply { Linkify.addLinks(this, Linkify.WEB_URLS) }
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
override fun getMangaUrl(manga: SManga) = manga.url
private fun Elements.selectFirstBackport(cssQuery: String) = selectFirst(cssQuery, this)
// backport from jsoup 1.19.1
private fun selectFirst(cssQuery: String, roots: Elements): Element? {
Validate.notEmpty(cssQuery)
Validate.notNull(roots)
val evaluator = QueryParser.parse(cssQuery)
for (root in roots) {
val first = Collector.findFirst(evaluator, root)
if (first != null) return first
}
return null
}
override fun popularMangaParse(response: Response) = MangasPage(
response.asJsoup()
.selectFirst("#main aside.primary-sidebar .sidebar-group")!!
.select("h2")
.filter { el -> el.text().contains("Latest", true) }
.map {
SManga.create().apply {
title = it.text().split('"')[1].unescape()
url = it.nextElementSiblings().selectFirstBackport("a[href*='/feed']")!!.attr("href").toHttpUrl().resolve("/").toString()
thumbnail_url = it.nextElementSiblings().selectFirstBackport("img")!!.attr("src")
}
},
false,
)
override fun mangaDetailsRequest(manga: SManga) =
GET(manga.url, headers)
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val html = response.asJsoup()
val thumbnailRegex = Regex("/[^/]+-([0-9]+\\.png)\$", RegexOption.IGNORE_CASE)
title = html.head().selectFirst("title")!!.text().unescape()
url = response.request.url.toString()
author = "Patricia Barton"
status = SManga.ONGOING
thumbnail_url =
html.select("#content img[src*='.png']")
.firstOrNull { it.attr("src").matches(thumbnailRegex) }
?.attr("src")
?.replace(thumbnailRegex, "/\$1")
}
override fun chapterListRequest(manga: SManga): Request = chapterListRequest(manga.url, 1)
private fun chapterListRequest(url: String, page: Int): Request =
GET(
"$url/wp-json/wp/v2/posts?per_page=100&page=$page&_fields=id,title,date_gmt,excerpt",
headers,
)
private fun hasNextPage(headers: Headers, responseSize: Int, page: Int): Boolean {
val pages = headers["X-Wp-Totalpages"]?.toInt()
?: return responseSize == 100
return page < pages
}
override fun getChapterUrl(chapter: SChapter): String = chapter.url
override fun chapterListParse(response: Response): List<SChapter> {
val apiResponse = response.parseAs<List<ChapterListDto>>().toMutableList()
val mangaUrl = response.request.url.toString().substringBefore("/wp-json/")
if (hasNextPage(response.headers, apiResponse.size, 1)) {
var page = 1
do {
page++
val tempResponse = client.newCall(chapterListRequest(mangaUrl, page)).execute()
val headers = tempResponse.headers
val tempApiResponse = tempResponse.parseAs<List<ChapterListDto>>()
apiResponse.addAll(tempApiResponse)
tempResponse.close()
} while (hasNextPage(headers, tempApiResponse.size, page))
}
return apiResponse
.filter { !it.excerpt.rendered.contains("Unlock with Patreon") }
.distinctBy { it.id }
.sortedBy { it.date }
.mapIndexed { i, it ->
SChapter.create().apply {
url = "$mangaUrl/?p=${it.id}"
name = it.title.rendered.unescape()
.ifEmpty { it.date.substringBefore('T') }
date_upload = uploadDateFormat.tryParse(it.date)
chapter_number = i.toFloat()
}
}.reversed()
}
// Adapted from the xkcd source's wordWrap function
private fun wordWrap(text: String) = buildString {
var charCount = 0
text.replace('\n', ' ').split(' ').forEach { w ->
if (charCount > 25) {
append("\n")
charCount = 0
}
append(w).append(' ')
charCount += w.length + 1
}
}
override fun pageListRequest(chapter: SChapter): Request {
val chapterUrl = chapter.url.toHttpUrl()
val pageListUrl = chapterUrl
.newBuilder("/wp-json/wp/v2/posts?per_page=1&_fields=link,content,excerpt,date,title")!!
.setQueryParameter("include", chapterUrl.queryParameter("p"))
.build()
return GET(pageListUrl.toString(), headers)
}
override fun pageListParse(response: Response): List<Page> {
val apiResponse: PageListDto = response.parseAs<List<PageListDto>>().first()
val content = Jsoup.parseBodyFragment(apiResponse.content.rendered, apiResponse.link)
val images = content.select("img")
.mapIndexed { i, it -> Page(i, "", it.attr("src")) }
.toMutableList()
if (apiResponse.excerpt.rendered.isNotBlank()) {
images.add(
Page(
images.size,
"",
wordWrap(Jsoup.parseBodyFragment(apiResponse.excerpt.rendered.unescape()).text()).image(),
),
)
}
return images.toList()
}
private val preferences: SharedPreferences by getPreferencesLazy()
companion object {
private const val WORDPRESS_USERNAME_PREF_KEY = "WORDPRESS_USERNAME"
private const val WORDPRESS_USERNAME_PREF_TITLE = "WordPress username"
private const val WORDPRESS_USERNAME_PREF_SUMMARY = "The WordPress username"
private const val WORDPRESS_USERNAME_PREF_DIALOG = "To see your username:\n\n" +
"Go to https://bodysuit23.mehgazone.com/wp-admin/profile.php and you should see your username near the top of the page."
private const val WORDPRESS_USERNAME_PREF_DEFAULT_VALUE = ""
private const val WORDPRESS_APP_PASSWORD_PREF_KEY = "WORDPRESS_APP_PASSWORD"
private const val WORDPRESS_APP_PASSWORD_PREF_TITLE = "WordPress app password"
private const val WORDPRESS_APP_PASSWORD_PREF_SUMMARY = "The WordPress app password (not your account password)"
private const val WORDPRESS_APP_PASSWORD_PREF_DIALOG = "To setup:\n\n" +
"Go to https://bodysuit23.mehgazone.com/wp-admin/profile.php and you should be able to create a new app password near the bottom of the page."
private const val WORDPRESS_APP_PASSWORD_PREF_DEFAULT_VALUE = ""
}
private val authInterceptor: BasicAuthInterceptor by lazy {
BasicAuthInterceptor(
preferences.getString(WORDPRESS_USERNAME_PREF_KEY, WORDPRESS_USERNAME_PREF_DEFAULT_VALUE),
preferences.getString(WORDPRESS_APP_PASSWORD_PREF_KEY, WORDPRESS_APP_PASSWORD_PREF_DEFAULT_VALUE),
)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
val name = preferences.getString(WORDPRESS_USERNAME_PREF_KEY, WORDPRESS_USERNAME_PREF_DEFAULT_VALUE)!!
key = WORDPRESS_USERNAME_PREF_KEY
title = WORDPRESS_USERNAME_PREF_TITLE
dialogMessage = WORDPRESS_USERNAME_PREF_DIALOG.linkify()
summary = name.ifBlank { WORDPRESS_USERNAME_PREF_SUMMARY }
setDefaultValue(WORDPRESS_USERNAME_PREF_DEFAULT_VALUE)
setOnBindEditTextListener {
getDialogMessageFromEditText(it).let {
@Suppress("NestedLambdaShadowedImplicitParameter")
if (it == null) {
Log.e(name, "Could not find dialog TextView")
} else {
it.movementMethod = LinkMovementMethod.getInstance()
}
}
}
setOnPreferenceChangeListener { preference, newValue ->
authInterceptor.setUser(newValue as String)
preference.summary = newValue.ifBlank { WORDPRESS_USERNAME_PREF_SUMMARY }
true
}
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
val pwd = preferences.getString(WORDPRESS_APP_PASSWORD_PREF_KEY, WORDPRESS_APP_PASSWORD_PREF_DEFAULT_VALUE)!!
key = WORDPRESS_APP_PASSWORD_PREF_KEY
title = WORDPRESS_APP_PASSWORD_PREF_TITLE
dialogMessage = WORDPRESS_APP_PASSWORD_PREF_DIALOG.linkify()
summary = if (pwd.isBlank()) WORDPRESS_APP_PASSWORD_PREF_SUMMARY else "".repeat(pwd.length)
setDefaultValue(WORDPRESS_APP_PASSWORD_PREF_DEFAULT_VALUE)
setOnBindEditTextListener {
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
getDialogMessageFromEditText(it).let {
@Suppress("NestedLambdaShadowedImplicitParameter")
if (it == null) {
Log.e(name, "Could not find dialog TextView")
} else {
it.movementMethod = LinkMovementMethod.getInstance()
}
}
}
setOnPreferenceChangeListener { preference, newValue ->
authInterceptor.setPassword(newValue as String)
preference.summary = if (newValue.isBlank()) WORDPRESS_APP_PASSWORD_PREF_SUMMARY else "".repeat(newValue.length)
true
}
}.also(screen::addPreference)
}
private fun getDialogMessageFromEditText(editText: EditText): TextView? {
val parent = editText.parent
if (parent !is ViewGroup || parent.childCount == 0) return null
for (i in 1..parent.childCount) {
val child = parent.getChildAt(i - 1)
if (child is TextView && child !is EditText) return child
}
return null
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
fetchPopularManga(0).map {
MangasPage(
it.mangas.filter { m -> m.title.contains(query) },
false,
)
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException()
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
}

View File

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.extension.en.mehgazone.interceptors
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.Response
class BasicAuthInterceptor(private var user: String?, private var password: String?) : Interceptor {
fun setUser(user: String?) {
setAuth(user, password)
}
fun setPassword(password: String?) {
setAuth(user, password)
}
fun setAuth(user: String?, password: String?) {
this.user = user
this.password = password
credentials = getCredentials()
}
private fun getCredentials(): String? =
if (!user.isNullOrBlank() && !password.isNullOrBlank()) {
Credentials.basic(user!!, password!!)
} else {
null
}
private var credentials: String? = getCredentials()
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (
!request.url.encodedPath.contains("/wp-json/wp/v2/") ||
user.isNullOrBlank() ||
password.isNullOrBlank() ||
credentials.isNullOrBlank()
) {
return chain.proceed(request)
}
val authenticatedRequest = request.newBuilder()
.header("Authorization", credentials!!)
.build()
return chain.proceed(authenticatedRequest)
}
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.extension.en.mehgazone.serialization
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class ChapterListDto(
val id: Int,
@SerialName("date_gmt")
val date: String,
val title: RenderedDto,
val excerpt: RenderedDto,
)
@Serializable
class PageListDto(
val link: String,
val content: RenderedDto,
val excerpt: RenderedDto,
)
@Serializable
class RenderedDto(
val rendered: String,
)