add Wolfdotcom (#6534)

* wolfdotcom

* fix selectors

* domain preference and auto update

* update domain number

* auto update domain in ci

* update regex

* use

* current domain number

* don't set chapter number as it is more of an index than actual chapter num
This commit is contained in:
AwkwardPeak7 2025-01-04 04:53:08 +05:00 committed by Draff
parent bb2e8d2cde
commit 6b8b650004
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
10 changed files with 553 additions and 0 deletions

View File

@ -0,0 +1,53 @@
ext {
extName = 'Wolf.com'
extClass = '.WolfFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"
def domainNumberFileName = "src/ko/wolfdotcom/src/eu/kanade/tachiyomi/extension/ko/wolfdotcom/DomainNumber.kt"
def domainNumberFile = new File(domainNumberFileName)
def backupFile = new File(domainNumberFileName + "_bak")
tasks.register('updateDomainNumber') {
doLast {
def domainNumber = -1
def response = new URL("https://nicelink52.com/").text
def matcher = response =~ ~/https?:\/\/wfwf(\d+)\.com/
if (matcher) {
domainNumber = matcher[0][1]
println("[Wolf.com] new domain number: $domainNumber")
} else {
println("[Wolf.com] domain number not found")
}
if (domainNumber != -1) {
domainNumberFile.renameTo(backupFile)
domainNumberFile.withPrintWriter {
it.println("// THIS FILE IS AUTO-GENERATED, DO NOT COMMIT")
it.println("package eu.kanade.tachiyomi.extension.ko.wolfdotcom")
it.println("const val DEFAULT_DOMAIN_NUMBER = \"$domainNumber\"")
}
}
}
}
preBuild.dependsOn updateDomainNumber
tasks.register('restoreBackup') {
doLast {
if (backupFile.exists()) {
println("[Wolf.com] Restoring placeholder file")
domainNumberFile.delete()
backupFile.renameTo(domainNumberFile)
}
}
}
tasks.configureEach { task ->
if (task.name == "assembleDebug" || task.name == "assembleRelease") {
task.finalizedBy(restoreBackup)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.extension.ko.wolfdotcom
const val DEFAULT_DOMAIN_NUMBER = "363"

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.extension.ko.wolfdotcom
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import okhttp3.HttpUrl
interface UrlPartFilter {
fun addToUrl(url: HttpUrl.Builder)
}
class FilterData(
val type: String,
private val typeDisplayName: String? = null,
val value: String?,
private val valueDisplayName: String,
) {
override fun toString(): String {
return "$typeDisplayName: $valueDisplayName"
}
}
class SearchFilter(
private val options: List<FilterData>,
) : Filter.Select<String>(
"필터",
options.map { it.toString() }.toTypedArray(),
),
UrlPartFilter {
override fun addToUrl(url: HttpUrl.Builder) {
val selected = options[state]
url.addQueryParameter("type1", selected.type)
selected.value?.let {
url.addQueryParameter("type2", it)
}
}
}
class SortFilter(default: Int = 0) :
Filter.Select<String>(
"정렬 기준",
options.map { it.first }.toTypedArray(),
default,
),
UrlPartFilter {
override fun addToUrl(url: HttpUrl.Builder) {
url.addQueryParameter("o", options[state].second)
}
companion object {
private val options = listOf(
"최신순" to "n",
"인기순" to "f",
)
}
}
val POPULAR = FilterList(SortFilter(1))
val LATEST = FilterList(SortFilter(0))

View File

@ -0,0 +1,417 @@
package eu.kanade.tachiyomi.extension.ko.wolfdotcom
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
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 eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.net.URLEncoder
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
open class Wolf(
name: String,
private val browsePath: String,
private val entryPath: String,
private val readerPath: String,
) : HttpSource(), ConfigurableSource {
override val name = "늑대닷컴 - $name"
override val lang = "ko"
override val baseUrl: String
get() = "https://wfwf$domainNumber.com"
override val supportsLatest = true
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::domainNumberInterceptor)
.build()
private val json: Json by injectLazy()
private val preference: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return fetchSearchManga(page, "", POPULAR)
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return fetchSearchManga(page, "", LATEST)
}
private var searchFilters: List<FilterData> = emptyList()
private var filterParseError = false
override fun getFilterList(): FilterList {
val filters: MutableList<Filter<*>> = mutableListOf(
SortFilter(),
)
if (searchFilters.isNotEmpty()) {
filters.add(
SearchFilter(searchFilters),
)
} else if (filterParseError) {
filters.add(
Filter.Header("unable to parse filters"),
)
} else {
filters.add(
Filter.Header("press 'reset' to attempt to load more filters"),
)
}
return FilterList(filters)
}
protected open fun parseSearchFilters(document: Document) {
if (searchFilters.isNotEmpty() || filterParseError) return
try {
val displayName = document.select(".sub-tab > a").eachText()
assert(displayName.size == 3)
searchFilters =
document.select(".tab-day > a, .tab-genre1 > a, .tab-genre2 > a, .tab-alphabet > a")
.map {
val url = it.absUrl("href").toHttpUrl()
val type = url.queryParameter("type1")!!
FilterData(
type = type,
typeDisplayName = when (type) {
"day", "complete" -> displayName[0]
"genre" -> displayName[1]
"alphabet" -> displayName[2]
else -> null
},
value = url.queryParameter("type2"),
valueDisplayName = it.ownText(),
)
}
} catch (e: Throwable) {
Log.e(name, "error parsing filters", e)
filterParseError = true
}
}
private lateinit var browseCache: List<List<BrowseItem>>
class BrowseItem(
val id: Int,
val title: String,
val cover: String?,
)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (query.isNotBlank()) {
return querySearch(query)
}
return if (page == 1) {
client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map {
parseBrowsePage(it)
paginatedBrowsePage(0)
}
} else {
Observable.just(
paginatedBrowsePage(page - 1),
)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/$browsePath".toHttpUrl().newBuilder().apply {
filters.filterIsInstance<UrlPartFilter>().forEach { filter ->
filter.addToUrl(this)
}
}.build()
return GET(url, headers)
}
private fun parseBrowsePage(response: Response) {
val document = response.asJsoup()
parseSearchFilters(document)
browseCache = document.select(".webtoon-list > ul > li > a").map {
val id = it.absUrl("href").toHttpUrl()
.queryParameter("toon")!!.toInt()
BrowseItem(
id = id,
title = it.selectFirst(".txt > .subject")!!.ownText(),
cover = it.selectFirst(".img > img")?.attr("data-original"),
)
}.chunked(20)
}
private fun paginatedBrowsePage(index: Int): MangasPage {
return MangasPage(
browseCache[index].map {
SManga.create().apply {
url = it.id.toString()
title = it.title
thumbnail_url = it.cover
}
},
browseCache.lastIndex > index,
)
}
private val specialChars = Regex("""[^\p{InHangul_Syllables}0-9a-z ]""", RegexOption.IGNORE_CASE)
private val styleImage = Regex("""background-image:url\(([^)]+)\)""")
private fun querySearch(query: String): Observable<MangasPage> {
if (query.length < 2) {
throw Exception("두 글자 이상 입력 해주세요.")
}
val stdQuery = query.replace(specialChars, "")
val searchUrl = "$baseUrl/search.html?q=${URLEncoder.encode(stdQuery, "EUC-KR")}"
return client.newCall(GET(searchUrl, headers))
.asObservableSuccess()
.map { response ->
val document = Jsoup.parseBodyFragment(response.body.string(), searchUrl)
val entries = document.select("article.searchItem")
.filter { el ->
el.selectFirst("a.searchLink")!!.attr("href").contains(entryPath)
}
.map { el ->
val mangaUrl = el.selectFirst("a.searchLink")!!.absUrl("href")
.toHttpUrl()
SManga.create().apply {
url = mangaUrl.queryParameter("toon")!!
title = el.selectFirst(".searchDetailTitle")!!.text()
thumbnail_url = el.selectFirst(".searchPng")
?.attr("style")
?.let {
styleImage.find(it)?.groupValues?.get(1)
}
}
}
MangasPage(entries, false)
}
}
override fun getMangaUrl(manga: SManga): String {
return baseUrl.toHttpUrl().newBuilder()
.addPathSegment(entryPath)
.addQueryParameter("toon", manga.url)
.toString()
}
override fun mangaDetailsRequest(manga: SManga): Request {
return GET(getMangaUrl(manga), headers)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
return SManga.create().apply {
title = document.selectFirst(".text-box h1")!!.text()
thumbnail_url = document.selectFirst(".img-box img")?.absUrl("src")
description = document.selectFirst(".text-box .txt")?.text()
genre = document.selectFirst(".text-box .sub:has(> strong:contains(장르))")?.ownText()?.replace("/", ", ")
author = document.selectFirst(".text-box .sub:has(> strong:contains(작가))")?.ownText()?.replace("/", ", ")
}
}
override fun chapterListRequest(manga: SManga): Request {
return mangaDetailsRequest(manga)
}
@Serializable
class ChapterUrl(
val toon: String,
val num: String,
)
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select(".webtoon-bbs-list a.view_open").map { el ->
val chapUrl = el.absUrl("href").toHttpUrl()
SChapter.create().apply {
url = json.encodeToString(
ChapterUrl(
chapUrl.queryParameter("toon")!!,
chapUrl.queryParameter("num")!!,
),
)
name = el.selectFirst(".subject")!!.ownText()
date_upload = el.selectFirst(".date")?.text().parseDate()
}
}
}
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
private fun String?.parseDate(): Long {
this ?: return 0L
return try {
dateFormat.parse(this)!!.time
} catch (_: ParseException) {
0L
}
}
override fun getChapterUrl(chapter: SChapter): String {
val chapUrl = json.decodeFromString<ChapterUrl>(chapter.url)
return baseUrl.toHttpUrl().newBuilder()
.addPathSegment(readerPath)
.addQueryParameter("toon", chapUrl.toon)
.addQueryParameter("num", chapUrl.num)
.toString()
}
override fun pageListRequest(chapter: SChapter): Request {
return GET(getChapterUrl(chapter), headers)
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select(".image-view img").mapIndexed { idx, img ->
Page(idx, imageUrl = img.absUrl("data-original"))
}
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = PREF_DOMAIN_NUM
title = "도메인 번호"
setOnPreferenceChangeListener { _, newValue ->
val value = newValue as String
if (value.isEmpty() || value.toIntOrNull() == null) {
false
} else {
domainNumber = value.trim()
false
}
}
}.also(screen::addPreference)
}
private var domainNumber = ""
get() {
val currentValue = field
if (currentValue.isNotEmpty()) return currentValue
val prefValue = preference.getString(PREF_DOMAIN_NUM, "")!!
val prefDefaultValue = preference.getString(PREF_DOMAIN_NUM_DEFAULT, "")!!
if (prefDefaultValue != DEFAULT_DOMAIN_NUMBER) {
preference.edit()
.putString(PREF_DOMAIN_NUM_DEFAULT, DEFAULT_DOMAIN_NUMBER)
.putString(PREF_DOMAIN_NUM, DEFAULT_DOMAIN_NUMBER)
.apply()
field = DEFAULT_DOMAIN_NUMBER
return DEFAULT_DOMAIN_NUMBER
}
if (prefValue.isNotEmpty()) {
field = prefValue
return prefValue
}
return DEFAULT_DOMAIN_NUMBER
}
set(value) {
preference.edit().putString(PREF_DOMAIN_NUM, value).apply()
field = value
}
private fun domainNumberInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val url = request.url.toString()
if (url.contains(domainRegex)) {
val document = Jsoup.parse(response.peekBody(Long.MAX_VALUE).string())
val newUrl = document.selectFirst("""#pop-content a[href~=^https?://wfwf\d+\.com]""")
?: return response
response.close()
val newDomainNum = domainRegex.find(newUrl.attr("href"))?.groupValues?.get(1)
?: throw IOException("Failed to update domain number")
domainNumber = newDomainNum.trim()
return chain.proceed(
request.newBuilder()
.url(
request.url.newBuilder()
.host(baseUrl.toHttpUrl().host)
.build(),
)
.build(),
)
}
return response
}
private val domainRegex = Regex("""^https?://wfwf(\d+)\.com""")
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
override fun popularMangaParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
override fun popularMangaRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun searchMangaParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
}
private const val PREF_DOMAIN_NUM = "domain_number"
private const val PREF_DOMAIN_NUM_DEFAULT = "domain_number_default"

View File

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.extension.ko.wolfdotcom
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.model.FilterList
import org.jsoup.nodes.Document
class WolfFactory : SourceFactory {
override fun createSources() = listOf(
Wolf("웹툰", "ing", "list", "view"), // webtoon
Wolf("만화책", "cm", "cl", "cv"), // comic book
object : Wolf("포토툰", "pt", "list", "view") { // phototoon
override fun getFilterList(): FilterList {
return FilterList()
}
override fun parseSearchFilters(document: Document) {
return
}
},
)
}