update src/en

This commit is contained in:
Anshuman Sarkar 2024-01-18 13:50:00 +05:30 committed by GitHub
parent e380f0282b
commit a1d7d4eae3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1476 additions and 0 deletions

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="eu.kanade.tachiyomi.multisrc.madara.MadaraUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${SOURCEHOST}"
android:pathPattern="/.*/..*"
android:scheme="${SOURCESCHEME}" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,29 @@
// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Kissmanga.in'
pkgNameSuffix = 'en.kissmangain'
extClass = '.KissmangaIn'
extFactory = 'madara'
extVersionCode = 35
}
dependencies {
implementation(project(":lib-cryptoaes"))
implementation(project(":lib-randomua"))
}
apply from: "$rootDir/common.gradle"
android {
defaultConfig {
manifestPlaceholders += [
SOURCEHOST: "kissmanga.in",
SOURCESCHEME: "https"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View file

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.extension.en.kissmangain
import eu.kanade.tachiyomi.multisrc.madara.Madara
class KissmangaIn : Madara("Kissmanga.in", "https://kissmanga.in", "en") {
override val mangaSubString = "kissmanga"
}

View file

@ -0,0 +1,971 @@
package eu.kanade.tachiyomi.multisrc.madara
import android.app.Application
import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
abstract class Madara(
override val name: String,
override val baseUrl: String,
final override val lang: String,
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US),
) : ParsedHttpSource(), ConfigurableSource {
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override val supportsLatest = true
override val client: OkHttpClient by lazy {
network.cloudflareClient.newBuilder()
.setRandomUserAgent(
preferences.getPrefUAType(),
preferences.getPrefCustomUA(),
)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
protected open val json: Json by injectLazy()
/**
* If enabled, will attempt to remove non-manga items in popular and latest.
* The filter will not be used in search as the theme doesn't set the CSS class.
* Can be disabled if the source incorrectly sets the entry types.
*/
protected open val filterNonMangaItems = true
/**
* The CSS selector used to filter manga items in popular and latest
* if `filterNonMangaItems` is set to `true`. Can be override if needed.
* If the flag is set to `false`, it will be empty by default.
*/
protected open val mangaEntrySelector: String by lazy {
if (filterNonMangaItems) ".manga" else ""
}
/**
* Automatically fetched genres from the source to be used in the filters.
*/
private var genresList: List<Genre> = emptyList()
/**
* Inner variable to control the genre fetching failed state.
*/
private var fetchGenresFailed: Boolean = false
/**
* Inner variable to control how much tries the genres request was called.
*/
private var fetchGenresAttempts: Int = 0
/**
* Disable it if you don't want the genres to be fetched.
*/
protected open val fetchGenres: Boolean = true
/**
* The path used in the URL for the manga pages. Can be
* changed if needed as some sites modify it to other words.
*/
protected open val mangaSubString = "manga"
// Popular Manga
override fun popularMangaParse(response: Response): MangasPage {
runCatching { fetchGenres() }
return super.popularMangaParse(response)
}
// exclude/filter bilibili manga from list
override fun popularMangaSelector() = "div.page-item-detail:not(:has(a[href*='bilibilicomics.com']))$mangaEntrySelector"
open val popularMangaUrlSelector = "div.post-title a"
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
with(element) {
select(popularMangaUrlSelector).first()?.let {
manga.setUrlWithoutDomain(it.attr("abs:href"))
manga.title = it.ownText()
}
select("img").first()?.let {
manga.thumbnail_url = imageFromElement(it)
}
}
return manga
}
override fun popularMangaRequest(page: Int): Request {
return GET(
url = "$baseUrl/$mangaSubString/${searchPage(page)}?m_orderby=views",
headers = headers,
cache = CacheControl.FORCE_NETWORK,
)
}
override fun popularMangaNextPageSelector(): String? = searchMangaNextPageSelector()
// Latest Updates
override fun latestUpdatesSelector() = popularMangaSelector()
override fun latestUpdatesFromElement(element: Element): SManga {
// Even if it's different from the popular manga's list, the relevant classes are the same
return popularMangaFromElement(element)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET(
url = "$baseUrl/$mangaSubString/${searchPage(page)}?m_orderby=latest",
headers = headers,
cache = CacheControl.FORCE_NETWORK,
)
}
override fun latestUpdatesNextPageSelector(): String? = popularMangaNextPageSelector()
override fun latestUpdatesParse(response: Response): MangasPage {
val mp = super.latestUpdatesParse(response)
val mangas = mp.mangas.distinctBy { it.url }
return MangasPage(mangas, mp.hasNextPage)
}
// Search Manga
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (query.startsWith(URL_SEARCH_PREFIX)) {
val mangaUrl = "$baseUrl/$mangaSubString/${query.substringAfter(URL_SEARCH_PREFIX)}"
return client.newCall(GET(mangaUrl, headers))
.asObservable().map { response ->
MangasPage(listOf(mangaDetailsParse(response.asJsoup()).apply { url = "/$mangaSubString/${query.substringAfter(URL_SEARCH_PREFIX)}/" }), false)
}
}
return client.newCall(searchMangaRequest(page, query, filters))
.asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
// Error message for exceeding last page
if (response.code == 404) {
error("Already on the Last Page!")
} else {
throw Exception("HTTP error ${response.code}")
}
}
}
.map { response ->
searchMangaParse(response)
}
}
protected open fun searchPage(page: Int): String = "page/$page/"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/${searchPage(page)}".toHttpUrlOrNull()!!.newBuilder()
url.addQueryParameter("s", query)
url.addQueryParameter("post_type", "wp-manga")
filters.forEach { filter ->
when (filter) {
is AuthorFilter -> {
if (filter.state.isNotBlank()) {
url.addQueryParameter("author", filter.state)
}
}
is ArtistFilter -> {
if (filter.state.isNotBlank()) {
url.addQueryParameter("artist", filter.state)
}
}
is YearFilter -> {
if (filter.state.isNotBlank()) {
url.addQueryParameter("release", filter.state)
}
}
is StatusFilter -> {
filter.state.forEach {
if (it.state) {
url.addQueryParameter("status[]", it.id)
}
}
}
is OrderByFilter -> {
if (filter.state != 0) {
url.addQueryParameter("m_orderby", filter.toUriPart())
}
}
is AdultContentFilter -> {
url.addQueryParameter("adult", filter.toUriPart())
}
is GenreConditionFilter -> {
url.addQueryParameter("op", filter.toUriPart())
}
is GenreList -> {
filter.state
.filter { it.state }
.let { list ->
if (list.isNotEmpty()) { list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) } }
}
}
else -> {}
}
}
return GET(url.toString(), headers)
}
protected open val authorFilterTitle: String = when (lang) {
"pt-BR" -> "Autor"
else -> "Author"
}
protected open val artistFilterTitle: String = when (lang) {
"pt-BR" -> "Artista"
else -> "Artist"
}
protected open val yearFilterTitle: String = when (lang) {
"pt-BR" -> "Ano de lançamento"
else -> "Year of Released"
}
protected open val statusFilterTitle: String = when (lang) {
"pt-BR" -> "Estado"
else -> "Status"
}
protected open val statusFilterOptions: Array<String> = when (lang) {
"pt-BR" -> arrayOf("Completo", "Em andamento", "Cancelado", "Pausado")
else -> arrayOf("Completed", "Ongoing", "Canceled", "On Hold")
}
protected val statusFilterOptionsValues: Array<String> = arrayOf(
"end",
"on-going",
"canceled",
"on-hold",
)
protected open val orderByFilterTitle: String = when (lang) {
"pt-BR" -> "Ordenar por"
else -> "Order By"
}
protected open val orderByFilterOptions: Array<String> = when (lang) {
"pt-BR" -> arrayOf(
"Relevância",
"Recentes",
"A-Z",
"Avaliação",
"Tendência",
"Visualizações",
"Novos",
)
else -> arrayOf(
"Relevance",
"Latest",
"A-Z",
"Rating",
"Trending",
"Most Views",
"New",
)
}
protected val orderByFilterOptionsValues: Array<String> = arrayOf(
"",
"latest",
"alphabet",
"rating",
"trending",
"views",
"new-manga",
)
protected open val genreConditionFilterTitle: String = when (lang) {
"pt-BR" -> "Operador dos gêneros"
else -> "Genre condition"
}
protected open val genreConditionFilterOptions: Array<String> = when (lang) {
"pt-BR" -> arrayOf("OU", "E")
else -> arrayOf("OR", "AND")
}
protected open val adultContentFilterTitle: String = when (lang) {
"pt-BR" -> "Conteúdo adulto"
else -> "Adult Content"
}
protected open val adultContentFilterOptions: Array<String> = when (lang) {
"pt-BR" -> arrayOf("Indiferente", "Nenhum", "Somente")
else -> arrayOf("All", "None", "Only")
}
protected open val genreFilterHeader: String = when (lang) {
"pt-BR" -> "O filtro de gêneros pode não funcionar"
else -> "Genres filter may not work for all sources"
}
protected open val genreFilterTitle: String = when (lang) {
"pt-BR" -> "Gêneros"
else -> "Genres"
}
protected open val genresMissingWarning: String = when (lang) {
"pt-BR" -> "Aperte 'Redefinir' para tentar mostrar os gêneros"
else -> "Press 'Reset' to attempt to show the genres"
}
protected class AuthorFilter(title: String) : Filter.Text(title)
protected class ArtistFilter(title: String) : Filter.Text(title)
protected class YearFilter(title: String) : Filter.Text(title)
protected class StatusFilter(title: String, status: List<Tag>) :
Filter.Group<Tag>(title, status)
protected class OrderByFilter(title: String, options: List<Pair<String, String>>, state: Int = 0) :
UriPartFilter(title, options.toTypedArray(), state)
protected class GenreConditionFilter(title: String, options: Array<String>) : UriPartFilter(
title,
options.zip(arrayOf("", "1")).toTypedArray(),
)
protected class AdultContentFilter(title: String, options: Array<String>) : UriPartFilter(
title,
options.zip(arrayOf("", "0", "1")).toTypedArray(),
)
protected class GenreList(title: String, genres: List<Genre>) : Filter.Group<Genre>(title, genres)
class Genre(name: String, val id: String = name) : Filter.CheckBox(name)
override fun getFilterList(): FilterList {
val filters = mutableListOf(
AuthorFilter(authorFilterTitle),
ArtistFilter(artistFilterTitle),
YearFilter(yearFilterTitle),
StatusFilter(statusFilterTitle, getStatusList()),
OrderByFilter(
title = orderByFilterTitle,
options = orderByFilterOptions.zip(orderByFilterOptionsValues),
state = 0,
),
AdultContentFilter(adultContentFilterTitle, adultContentFilterOptions),
)
if (genresList.isNotEmpty()) {
filters += listOf(
Filter.Separator(),
Filter.Header(genreFilterHeader),
GenreConditionFilter(genreConditionFilterTitle, genreConditionFilterOptions),
GenreList(genreFilterTitle, genresList),
)
} else if (fetchGenres) {
filters += listOf(
Filter.Separator(),
Filter.Header(genresMissingWarning),
)
}
return FilterList(filters)
}
protected fun getStatusList() = statusFilterOptionsValues
.zip(statusFilterOptions)
.map { Tag(it.first, it.second) }
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>, state: Int = 0) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
fun toUriPart() = vals[state].second
}
open class Tag(val id: String, name: String) : Filter.CheckBox(name)
override fun searchMangaParse(response: Response): MangasPage {
runCatching { fetchGenres() }
return super.searchMangaParse(response)
}
override fun searchMangaSelector() = "div.c-tabs-item__content"
override fun searchMangaFromElement(element: Element): SManga {
val manga = SManga.create()
with(element) {
select("div.post-title a").first()?.let {
manga.setUrlWithoutDomain(it.attr("abs:href"))
manga.title = it.ownText()
}
select("img").first()?.let {
manga.thumbnail_url = imageFromElement(it)
}
}
return manga
}
override fun searchMangaNextPageSelector(): String? = "div.nav-previous, nav.navigation-ajax, a.nextpostslink"
// Manga Details Parse
protected val completedStatusList: Array<String> = arrayOf(
"Completed",
"Completo",
"Completado",
"Concluído",
"Concluido",
"Finalizado",
"Achevé",
"Terminé",
"Hoàn Thành",
"مكتملة",
"مكتمل",
"已完结",
)
protected val ongoingStatusList: Array<String> = arrayOf(
"OnGoing", "Продолжается", "Updating", "Em Lançamento", "Em lançamento", "Em andamento",
"Em Andamento", "En cours", "En Cours", "En cours de publication", "Ativo", "Lançando", "Đang Tiến Hành", "Devam Ediyor",
"Devam ediyor", "In Corso", "In Arrivo", "مستمرة", "مستمر", "En Curso", "En curso", "Emision",
"Curso", "En marcha", "Publicandose", "En emision", "连载中", "Em Lançamento",
)
protected val hiatusStatusList: Array<String> = arrayOf(
"On Hold",
"Pausado",
"En espera",
)
protected val canceledStatusList: Array<String> = arrayOf(
"Canceled",
"Cancelado",
)
override fun mangaDetailsParse(document: Document): SManga {
val manga = SManga.create()
with(document) {
select(mangaDetailsSelectorTitle).first()?.let {
manga.title = it.ownText()
}
select(mangaDetailsSelectorAuthor).eachText().filter {
it.notUpdating()
}.joinToString().takeIf { it.isNotBlank() }?.let {
manga.author = it
}
select(mangaDetailsSelectorArtist).eachText().filter {
it.notUpdating()
}.joinToString().takeIf { it.isNotBlank() }?.let {
manga.artist = it
}
select(mangaDetailsSelectorDescription).let {
if (it.select("p").text().isNotEmpty()) {
manga.description = it.select("p").joinToString(separator = "\n\n") { p ->
p.text().replace("<br>", "\n")
}
} else {
manga.description = it.text()
}
}
select(mangaDetailsSelectorThumbnail).first()?.let {
manga.thumbnail_url = imageFromElement(it)
}
select(mangaDetailsSelectorStatus).last()?.let {
manga.status = with(it.text()) {
when {
containsIn(completedStatusList) -> SManga.COMPLETED
containsIn(ongoingStatusList) -> SManga.ONGOING
containsIn(hiatusStatusList) -> SManga.ON_HIATUS
containsIn(canceledStatusList) -> SManga.CANCELLED
else -> SManga.UNKNOWN
}
}
}
val genres = select(mangaDetailsSelectorGenre)
.map { element -> element.text().lowercase(Locale.ROOT) }
.toMutableSet()
// add tag(s) to genre
val mangaTitle = try {
manga.title
} catch (_: UninitializedPropertyAccessException) {
"not initialized"
}
if (mangaDetailsSelectorTag.isNotEmpty()) {
select(mangaDetailsSelectorTag).forEach { element ->
if (genres.contains(element.text()).not() &&
element.text().length <= 25 &&
element.text().contains("read", true).not() &&
element.text().contains(name, true).not() &&
element.text().contains(name.replace(" ", ""), true).not() &&
element.text().contains(mangaTitle, true).not() &&
element.text().contains(altName, true).not()
) {
genres.add(element.text().lowercase(Locale.ROOT))
}
}
}
// add manga/manhwa/manhua thinggy to genre
document.select(seriesTypeSelector).firstOrNull()?.ownText()?.let {
if (it.isEmpty().not() && it.notUpdating() && it != "-" && genres.contains(it).not()) {
genres.add(it.lowercase(Locale.ROOT))
}
}
manga.genre = genres.toList().joinToString(", ") { genre ->
genre.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(
Locale.ROOT,
)
} else {
it.toString()
}
}
}
// add alternative name to manga description
document.select(altNameSelector).firstOrNull()?.ownText()?.let {
if (it.isBlank().not() && it.notUpdating()) {
manga.description = when {
manga.description.isNullOrBlank() -> altName + it
else -> manga.description + "\n\n$altName" + it
}
}
}
}
return manga
}
// Manga Details Selector
open val mangaDetailsSelectorTitle = "div.post-title h3, div.post-title h1"
open val mangaDetailsSelectorAuthor = "div.author-content > a"
open val mangaDetailsSelectorArtist = "div.artist-content > a"
open val mangaDetailsSelectorStatus = "div.summary-content"
open val mangaDetailsSelectorDescription = "div.description-summary div.summary__content, div.summary_content div.post-content_item > h5 + div, div.summary_content div.manga-excerpt"
open val mangaDetailsSelectorThumbnail = "div.summary_image img"
open val mangaDetailsSelectorGenre = "div.genres-content a"
open val mangaDetailsSelectorTag = "div.tags-content a"
open val seriesTypeSelector = ".post-content_item:contains(Type) .summary-content"
open val altNameSelector = ".post-content_item:contains(Alt) .summary-content"
open val altName = when (lang) {
"pt-BR" -> "Nomes alternativos: "
else -> "Alternative Names: "
}
open val updatingRegex = "Updating|Atualizando".toRegex(RegexOption.IGNORE_CASE)
fun String.notUpdating(): Boolean {
return this.contains(updatingRegex).not()
}
fun String.containsIn(array: Array<String>): Boolean {
return this.lowercase() in array.map { it.lowercase() }
}
protected open fun imageFromElement(element: Element): String? {
return when {
element.hasAttr("data-src") -> element.attr("abs:data-src")
element.hasAttr("data-lazy-src") -> element.attr("abs:data-lazy-src")
element.hasAttr("srcset") -> element.attr("abs:srcset").substringBefore(" ")
else -> element.attr("abs:src")
}
}
/**
* Set it to true if the source uses the new AJAX endpoint to
* fetch the manga chapters instead of the old admin-ajax.php one.
*/
protected open val useNewChapterEndpoint: Boolean = false
/**
* Internal attribute to control if it should always use the
* new chapter endpoint after a first check if useNewChapterEndpoint is
* set to false. Using a separate variable to still allow the other
* one to be overridable manually in each source.
*/
private var oldChapterEndpointDisabled: Boolean = false
protected open fun oldXhrChaptersRequest(mangaId: String): Request {
val form = FormBody.Builder()
.add("action", "manga_get_chapters")
.add("manga", mangaId)
.build()
val xhrHeaders = headersBuilder()
.add("Content-Length", form.contentLength().toString())
.add("Content-Type", form.contentType().toString())
.add("X-Requested-With", "XMLHttpRequest")
.build()
return POST("$baseUrl/wp-admin/admin-ajax.php", xhrHeaders, form)
}
protected open fun xhrChaptersRequest(mangaUrl: String): Request {
val xhrHeaders = headersBuilder()
.add("X-Requested-With", "XMLHttpRequest")
.build()
return POST("$mangaUrl/ajax/chapters", xhrHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val chaptersWrapper = document.select("div[id^=manga-chapters-holder]")
var chapterElements = document.select(chapterListSelector())
if (chapterElements.isEmpty() && !chaptersWrapper.isNullOrEmpty()) {
val mangaUrl = document.location().removeSuffix("/")
val mangaId = chaptersWrapper.attr("data-id")
var xhrRequest = if (useNewChapterEndpoint || oldChapterEndpointDisabled) {
xhrChaptersRequest(mangaUrl)
} else {
oldXhrChaptersRequest(mangaId)
}
var xhrResponse = client.newCall(xhrRequest).execute()
// Newer Madara versions throws HTTP 400 when using the old endpoint.
if (!useNewChapterEndpoint && xhrResponse.code == 400) {
xhrResponse.close()
// Set it to true so following calls will be made directly to the new endpoint.
oldChapterEndpointDisabled = true
xhrRequest = xhrChaptersRequest(mangaUrl)
xhrResponse = client.newCall(xhrRequest).execute()
}
chapterElements = xhrResponse.asJsoup().select(chapterListSelector())
xhrResponse.close()
}
countViews(document)
return chapterElements.map(::chapterFromElement)
}
override fun chapterListSelector() = "li.wp-manga-chapter"
protected open fun chapterDateSelector() = "span.chapter-release-date"
open val chapterUrlSelector = "a"
// can cause some issue for some site. blocked by cloudflare when opening the chapter pages
open val chapterUrlSuffix = "?style=list"
override fun chapterFromElement(element: Element): SChapter {
val chapter = SChapter.create()
with(element) {
select(chapterUrlSelector).first()?.let { urlElement ->
chapter.url = urlElement.attr("abs:href").let {
it.substringBefore("?style=paged") + if (!it.endsWith(chapterUrlSuffix)) chapterUrlSuffix else ""
}
chapter.name = urlElement.text()
}
// Dates can be part of a "new" graphic or plain text
// Added "title" alternative
chapter.date_upload = select("img:not(.thumb)").firstOrNull()?.attr("alt")?.let { parseRelativeDate(it) }
?: select("span a").firstOrNull()?.attr("title")?.let { parseRelativeDate(it) }
?: parseChapterDate(select(chapterDateSelector()).firstOrNull()?.text())
}
return chapter
}
open fun parseChapterDate(date: String?): Long {
date ?: return 0
fun SimpleDateFormat.tryParse(string: String): Long {
return try {
parse(string)?.time ?: 0
} catch (_: ParseException) {
0
}
}
return when {
// Handle 'yesterday' and 'today', using midnight
WordSet("yesterday", "يوم واحد").startsWith(date) -> {
Calendar.getInstance().apply {
add(Calendar.DAY_OF_MONTH, -1) // yesterday
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
WordSet("today").startsWith(date) -> {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
WordSet("يومين").startsWith(date) -> {
Calendar.getInstance().apply {
add(Calendar.DAY_OF_MONTH, -2) // day before yesterday
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
parseRelativeDate(date)
}
WordSet("hace").startsWith(date) -> {
parseRelativeDate(date)
}
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
date.split(" ").map {
if (it.contains(Regex("""\d\D\D"""))) {
it.replace(Regex("""\D"""), "")
} else {
it
}
}
.let { dateFormat.tryParse(it.joinToString(" ")) }
}
else -> dateFormat.tryParse(date)
}
}
// Parses dates in this form:
// 21 horas ago
protected open fun parseRelativeDate(date: String): Long {
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance()
return when {
WordSet("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
WordSet("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
WordSet("detik", "segundo", "second", "วินาที").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
WordSet("week", "semana").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
WordSet("month", "mes").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("year", "año").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
else -> 0
}
}
override fun pageListRequest(chapter: SChapter): Request {
if (chapter.url.startsWith("http")) {
return GET(chapter.url, headers)
}
return super.pageListRequest(chapter)
}
open val pageListParseSelector = "div.page-break, li.blocks-gallery-item, .reading-content .text-left:not(:has(.blocks-gallery-item)) img"
open val chapterProtectorSelector = "#chapter-protector-data"
override fun pageListParse(document: Document): List<Page> {
countViews(document)
val chapterProtector = document.selectFirst(chapterProtectorSelector)
?: return document.select(pageListParseSelector).mapIndexed { index, element ->
val imageUrl = element.selectFirst("img")?.let { imageFromElement(it) }
Page(index, document.location(), imageUrl)
}
val chapterProtectorHtml = chapterProtector.html()
val password = chapterProtectorHtml
.substringAfter("wpmangaprotectornonce='")
.substringBefore("';")
val chapterData = json.parseToJsonElement(
chapterProtectorHtml
.substringAfter("chapter_data='")
.substringBefore("';")
.replace("\\/", "/"),
).jsonObject
val unsaltedCiphertext = Base64.decode(chapterData["ct"]!!.jsonPrimitive.content, Base64.DEFAULT)
val salt = chapterData["s"]!!.jsonPrimitive.content.decodeHex()
val ciphertext = SALTED + salt + unsaltedCiphertext
val rawImgArray = CryptoAES.decrypt(Base64.encodeToString(ciphertext, Base64.DEFAULT), password)
val imgArrayString = json.parseToJsonElement(rawImgArray).jsonPrimitive.content
val imgArray = json.parseToJsonElement(imgArrayString).jsonArray
return imgArray.mapIndexed { idx, it ->
Page(idx, document.location(), it.jsonPrimitive.content)
}
}
override fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers.newBuilder().set("Referer", page.url).build())
}
override fun imageUrlParse(document: Document) = ""
/**
* Set it to false if you want to disable the extension reporting the view count
* back to the source website through admin-ajax.php.
*/
protected open val sendViewCount: Boolean = true
protected open fun countViewsRequest(document: Document): Request? {
val wpMangaData = document.select("script#wp-manga-js-extra").firstOrNull()
?.data() ?: return null
val wpMangaInfo = wpMangaData
.substringAfter("var manga = ")
.substringBeforeLast(";")
val wpManga = runCatching { json.parseToJsonElement(wpMangaInfo).jsonObject }
.getOrNull() ?: return null
if (wpManga["enable_manga_view"]?.jsonPrimitive?.content == "1") {
val formBuilder = FormBody.Builder()
.add("action", "manga_views")
.add("manga", wpManga["manga_id"]!!.jsonPrimitive.content)
if (wpManga["chapter_slug"] != null) {
formBuilder.add("chapter", wpManga["chapter_slug"]!!.jsonPrimitive.content)
}
val formBody = formBuilder.build()
val newHeaders = headersBuilder()
.set("Content-Length", formBody.contentLength().toString())
.set("Content-Type", formBody.contentType().toString())
.set("Referer", document.location())
.build()
val ajaxUrl = wpManga["ajax_url"]!!.jsonPrimitive.content
return POST(ajaxUrl, newHeaders, formBody)
}
return null
}
/**
* Send the view count request to the Madara endpoint.
*
* @param document The response document with the wp-manga data
*/
protected open fun countViews(document: Document) {
if (!sendViewCount) {
return
}
val request = countViewsRequest(document) ?: return
runCatching { client.newCall(request).execute().close() }
}
/**
* Fetch the genres from the source to be used in the filters.
*/
protected open fun fetchGenres() {
if (fetchGenres && fetchGenresAttempts <= 3 && (genresList.isEmpty() || fetchGenresFailed)) {
val genres = runCatching {
client.newCall(genresRequest()).execute()
.use { parseGenres(it.asJsoup()) }
}
fetchGenresFailed = genres.isFailure
genresList = genres.getOrNull().orEmpty()
fetchGenresAttempts++
}
}
/**
* The request to the search page (or another one) that have the genres list.
*/
protected open fun genresRequest(): Request {
return GET("$baseUrl/?s=genre&post_type=wp-manga", headers)
}
/**
* Get the genres from the search page document.
*
* @param document The search page document
*/
protected open fun parseGenres(document: Document): List<Genre> {
return document.selectFirst("div.checkbox-group")
?.select("div.checkbox")
.orEmpty()
.map { li ->
Genre(
li.selectFirst("label")!!.text(),
li.selectFirst("input[type=checkbox]")!!.`val`(),
)
}
}
// https://stackoverflow.com/a/66614516
private fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
addRandomUAPreferenceToScreen(screen)
}
companion object {
const val URL_SEARCH_PREFIX = "slug:"
val SALTED = "Salted__".toByteArray(Charsets.UTF_8)
}
}
class WordSet(private vararg val words: String) {
fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) }
fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) }
fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) }
}

View file

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.multisrc.madara
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class MadaraUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size >= 2) {
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${getSLUG(pathSegments)}")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MadaraUrl", e.toString())
}
} else {
Log.e("MadaraUrl", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
private fun getSLUG(pathSegments: MutableList<String>): String? {
return if (pathSegments.size >= 2) {
val slug = pathSegments[1]
"${Madara.URL_SEARCH_PREFIX}$slug"
} else {
null
}
}
}

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- THIS FILE IS AUTO-GENERATED; DO NOT EDIT -->
<manifest />

View file

@ -0,0 +1,26 @@
// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'MangaBuddy'
pkgNameSuffix = 'en.mangabuddy'
extClass = '.MangaBuddy'
extFactory = 'madtheme'
extVersionCode = 15
isNsfw = true
}
apply from: "$rootDir/common.gradle"
android {
defaultConfig {
manifestPlaceholders += [
SOURCEHOST: "mangabuddy.com",
SOURCESCHEME: "https"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,8 @@
/* ktlint-disable */
// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
package eu.kanade.tachiyomi.extension.en.mangabuddy
import eu.kanade.tachiyomi.multisrc.madtheme.MadTheme
class MangaBuddy : MadTheme("MangaBuddy", "https://mangabuddy.com", "en")

View file

@ -0,0 +1,368 @@
package eu.kanade.tachiyomi.multisrc.madtheme
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.interceptor.rateLimit
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
abstract class MadTheme(
override val name: String,
override val baseUrl: String,
override val lang: String,
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM dd, yyy", Locale.US),
) : ParsedHttpSource() {
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(1, 1)
.build()
// TODO: better cookie sharing
// TODO: don't count cached responses against rate limit
private val chapterClient: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(1, 12)
.build()
override fun headersBuilder() = Headers.Builder().apply {
add("Referer", "$baseUrl/")
}
private val json: Json by injectLazy()
// Popular
override fun popularMangaRequest(page: Int): Request =
searchMangaRequest(page, "", FilterList(OrderFilter(0)))
override fun popularMangaParse(response: Response): MangasPage =
searchMangaParse(response)
override fun popularMangaSelector(): String =
searchMangaSelector()
override fun popularMangaFromElement(element: Element): SManga =
searchMangaFromElement(element)
override fun popularMangaNextPageSelector(): String? =
searchMangaNextPageSelector()
// Latest
override fun latestUpdatesRequest(page: Int): Request =
searchMangaRequest(page, "", FilterList(OrderFilter(1)))
override fun latestUpdatesParse(response: Response): MangasPage =
searchMangaParse(response)
override fun latestUpdatesSelector(): String =
searchMangaSelector()
override fun latestUpdatesFromElement(element: Element): SManga =
searchMangaFromElement(element)
override fun latestUpdatesNextPageSelector(): String? =
searchMangaNextPageSelector()
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/search".toHttpUrl().newBuilder()
.addQueryParameter("q", query)
.addQueryParameter("page", page.toString())
filters.forEach { filter ->
when (filter) {
is GenreFilter -> {
filter.state
.filter { it.state }
.let { list ->
if (list.isNotEmpty()) {
list.forEach { genre -> url.addQueryParameter("genre[]", genre.id) }
}
}
}
is StatusFilter -> {
url.addQueryParameter("status", filter.toUriPart())
}
is OrderFilter -> {
url.addQueryParameter("sort", filter.toUriPart())
}
else -> {}
}
}
return GET(url.toString(), headers)
}
override fun searchMangaSelector(): String = ".book-detailed-item"
override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply {
setUrlWithoutDomain(element.select("a").first()!!.attr("abs:href"))
title = element.select("a").first()!!.attr("title")
description = element.select(".summary").first()?.text()
genre = element.select(".genres > *").joinToString { it.text() }
thumbnail_url = element.select("img").first()!!.attr("abs:data-src")
}
/*
* Only some sites use the next/previous buttons, so instead we check for the next link
* after the active one. We use the :not() selector to exclude the optional next button
*/
override fun searchMangaNextPageSelector(): String? = ".paginator > a.active + a:not([rel=next])"
// Details
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
title = document.select(".detail h1").first()!!.text()
author = document.select(".detail .meta > p > strong:contains(Authors) ~ a").joinToString { it.text().trim(',', ' ') }
genre = document.select(".detail .meta > p > strong:contains(Genres) ~ a").joinToString { it.text().trim(',', ' ') }
thumbnail_url = document.select("#cover img").first()!!.attr("abs:data-src")
val altNames = document.select(".detail h2").first()?.text()
?.split(',', ';')
?.mapNotNull { it.trim().takeIf { it != title } }
?: listOf()
description = document.select(".summary .content").first()?.text() +
(altNames.takeIf { it.isNotEmpty() }?.let { "\n\nAlt name(s): ${it.joinToString()}" } ?: "")
val statusText = document.select(".detail .meta > p > strong:contains(Status) ~ a").first()!!.text()
status = when (statusText.lowercase(Locale.US)) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
// Chapters
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
// API is heavily rate limited. Use custom client
return if (manga.status != SManga.LICENSED) {
chapterClient.newCall(chapterListRequest(manga))
.asObservable()
.map { response ->
chapterListParse(response)
}
} else {
Observable.error(Exception("Licensed - No chapters to show"))
}
}
override fun chapterListParse(response: Response): List<SChapter> {
if (response.code in 200..299) {
return super.chapterListParse(response)
}
// Try to show message/error from site
response.body.let { body ->
json.decodeFromString<JsonObject>(body.string())["message"]
?.jsonPrimitive
?.content
?.let { throw Exception(it) }
}
throw Exception("HTTP error ${response.code}")
}
override fun chapterListRequest(manga: SManga): Request =
GET("$baseUrl/api/manga${manga.url}/chapters?source=detail", headers)
override fun searchMangaParse(response: Response): MangasPage {
if (genresList == null) {
genresList = parseGenres(response.asJsoup(response.peekBody(Long.MAX_VALUE).string()))
}
return super.searchMangaParse(response)
}
override fun chapterListSelector(): String = "#chapter-list > li"
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
// Not using setUrlWithoutDomain() to support external chapters
url = element.selectFirst("a")!!
.absUrl("href")
.removePrefix(baseUrl)
name = element.select(".chapter-title").first()!!.text()
date_upload = parseChapterDate(element.select(".chapter-update").first()?.text())
}
// Pages
override fun pageListParse(document: Document): List<Page> {
val html = document.html()
if (!html.contains("var mainServer = \"")) {
val chapterImagesFromHtml = document.select("#chapter-images img")
// 17/03/2023: Certain hosts only embed two pages in their "#chapter-images" and leave
// the rest to be lazily(?) loaded by javascript. Let's extract `chapImages` and compare
// the count against our select query. If both counts are the same, extract the original
// images directly from the <img> tags otherwise pick the higher count. (heuristic)
// First things first, let's verify `chapImages` actually exists.
if (html.contains("var chapImages = '")) {
val chapterImagesFromJs = html
.substringAfter("var chapImages = '")
.substringBefore("'")
.split(',')
// Make sure chapter images we've got from javascript all have a host, otherwise
// we've got no choice but to fallback to chapter images from HTML.
// TODO: This might need to be solved one day ^
if (chapterImagesFromJs.all { e ->
e.startsWith("http://") || e.startsWith("https://")
}
) {
// Great, we can use these.
if (chapterImagesFromHtml.count() < chapterImagesFromJs.count()) {
// Seems like we've hit such a host, let's use the images we've obtained
// from the javascript string.
return chapterImagesFromJs.mapIndexed { index, path ->
Page(index, imageUrl = path)
}
}
}
}
// No fancy CDN, all images are available directly in <img> tags (hopefully)
return chapterImagesFromHtml.mapIndexed { index, element ->
Page(index, imageUrl = element.attr("abs:data-src"))
}
}
// While the site may support multiple CDN hosts, we have opted to ignore those
val mainServer = html
.substringAfter("var mainServer = \"")
.substringBefore("\"")
val schemePrefix = if (mainServer.startsWith("//")) "https:" else ""
val chapImages = html
.substringAfter("var chapImages = '")
.substringBefore("'")
.split(',')
return chapImages.mapIndexed { index, path ->
Page(index, imageUrl = "$schemePrefix$mainServer$path")
}
}
// Image
override fun pageListRequest(chapter: SChapter): Request {
return if (chapter.url.toHttpUrlOrNull() != null) {
// External chapter
GET(chapter.url, headers)
} else {
super.pageListRequest(chapter)
}
}
override fun imageUrlParse(document: Document): String =
throw UnsupportedOperationException("Not used.")
// Date logic lifted from Madara
private fun parseChapterDate(date: String?): Long {
date ?: return 0
fun SimpleDateFormat.tryParse(string: String): Long {
return try {
parse(string)?.time ?: 0
} catch (_: ParseException) {
0
}
}
return when {
"ago".endsWith(date) -> {
parseRelativeDate(date)
}
else -> dateFormat.tryParse(date)
}
}
private fun parseRelativeDate(date: String): Long {
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance()
return when {
date.contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
date.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
date.contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
date.contains("second") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
else -> 0
}
}
// Dynamic genres
private fun parseGenres(document: Document): List<Genre>? {
return document.select(".checkbox-group.genres").first()?.select("label")?.map {
Genre(it.select(".radio__label").first()!!.text(), it.select("input").`val`())
}
}
// Filters
override fun getFilterList() = FilterList(
GenreFilter(getGenreList()),
StatusFilter(),
OrderFilter(),
)
private class GenreFilter(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
private class Genre(name: String, val id: String) : Filter.CheckBox(name)
private var genresList: List<Genre>? = null
private fun getGenreList(): List<Genre> {
// Filters are fetched immediately once an extension loads
// We're only able to get filters after a loading the manga directory, and resetting
// the filters is the only thing that seems to reinflate the view
return genresList ?: listOf(Genre("Press reset to attempt to fetch genres", ""))
}
class StatusFilter : UriPartFilter(
"Status",
arrayOf(
Pair("All", "all"),
Pair("Ongoing", "ongoing"),
Pair("Completed", "completed"),
),
)
class OrderFilter(state: Int = 0) : UriPartFilter(
"Order By",
arrayOf(
Pair("Views", "views"),
Pair("Updated", "updated_at"),
Pair("Created", "created_at"),
Pair("Name A-Z", "name"),
Pair("Rating", "rating"),
),
state,
)
open class UriPartFilter(
displayName: String,
private val vals: Array<Pair<String, String>>,
state: Int = 0,
) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
fun toUriPart() = vals[state].second
}
}