Add source GocTruyenTranhVui (#9728)

* Add GocTruyenTranhVui

* Use jsonInstance

* Use parseAs

* Use HttpSource()

* Merge DTO files

* Using chapterListParse and loginRequired

* Fix variable

* Use toManga(), Use toChapter(), fix no chapter

* Fix Url, Works well

* Add Advanced search

* Optimize variable naming & add mangaId cache

* MangaIdCache: Add limit cache

* Apply suggestion

* Fix package declaration, format using Android Studio

* Fix names: use camel case Dto instead of DTO even if it's acronym; add S in to[S]Manga/Chapter

* Use generic ResultDto<T> to replace similar classes

* Inline the typealiases, which are used to demonstrate how to use generics

* More conventional namings

* Change manga url format; override getMangaUrl; fix chapterListParse slug which is definitely not tested; remove useless HTML parse fallback

* Use timestamp value from API instead of parsing string

* Early abort in pageListParse()

* Refactor filters; don't get an empty filter list if argument is empty, it's uselss

* Parse more manga fields from API and set initialized because all fields are filled; fix listing next page; use selectFirst()!!.text() instead of select().text()

* Use search endpoint for latest updates; the home endpoint doesn't provide genres

---------

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>
This commit is contained in:
are-are-are 2025-07-26 00:42:57 +07:00 committed by Draff
parent 88749bec9f
commit ffff87d5a0
Signed by: Draff
GPG Key ID: E8A89F3211677653
9 changed files with 290 additions and 0 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.extension.vi.goctruyentranhvui
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
@Serializable
class ResultDto<T>(
val result: T,
)
@Serializable
class ChapterDto(
private val numberChapter: String,
private val updateTime: Long,
) {
fun toSChapter(slug: String): SChapter = SChapter.create().apply {
name = numberChapter
date_upload = updateTime
url = "/truyen/$slug/chuong-$numberChapter"
}
}
@Serializable
class ChapterListDto(
val chapters: List<ChapterDto>,
)
@Serializable
class ListingDto(
val next: Boolean,
val data: List<MangaDto>,
)
@Serializable
class MangaDto(
private val id: String,
private val name: String,
private val description: String,
private val statusCode: String,
private val photo: String,
private val nameEn: String,
private val author: String,
private val category: List<String>? = null,
) {
fun toSManga(baseUrl: String): SManga = SManga.create().apply {
title = name
thumbnail_url = baseUrl + photo
url = "$id:$nameEn"
author = this@MangaDto.author
description = this@MangaDto.description
genre = category?.joinToString()
status = when (statusCode) {
"PRG" -> SManga.ONGOING
"END" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
initialized = true
}
}
@Serializable
class ImageListWrapper(
val body: ResultDto<ImageListDto>,
)
@Serializable
class ImageListDto(
val data: List<String>,
)

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.extension.vi.goctruyentranhvui
import eu.kanade.tachiyomi.source.model.Filter
class Option(name: String, val id: String) : Filter.CheckBox(name)
open class FilterGroup(name: String, val query: String, state: List<Option>) : Filter.Group<Option>(name, state)
class StatusList(status: List<Option>) : FilterGroup("Trạng Thái", "status[]", status)
fun getStatusList() = listOf(
Option("Đang thực hiện", "PRG"),
Option("Hoàn thành", "END"),
Option("Truyện Chữ", "novel"),
)
class SortByList(sort: List<Option>) : FilterGroup("Sắp xếp", "orders[]", sort)
fun getSortByList() = listOf(
Option("Lượt xem", "viewCount"),
Option("Lượt đánh giá", "evaluationScore"),
Option("Lượt theo dõi", "followerCount"),
Option("Ngày Cập Nhật", "recentDate"),
Option("Truyện Mới", "createdAt"),
)
class GenreList(genres: List<Option>) : FilterGroup("Thể loại", "categories[]", genres)
fun getGenreList() = listOf(
Option("Anime", "ANI"),
Option("Drama", "DRA"),
Option("Josei", "JOS"),
Option("Manhwa", "MAW"),
Option("One Shot", "OSH"),
Option("Shounen", "SHO"),
Option("Webtoons", "WEB"),
Option("Shoujo", "SHJ"),
Option("Harem", "HAR"),
Option("Ecchi", "ECC"),
Option("Mature", "MAT"),
Option("Slice of life", "SOL"),
Option("Isekai", "ISE"),
Option("Manga", "MAG"),
Option("Manhua", "MAU"),
Option("Hành Động", "ACT"),
Option("Phiêu Lưu", "ADV"),
Option("Hài Hước", "COM"),
Option("Võ Thuật", "MAA"),
Option("Huyền Bí", "MYS"),
Option("Lãng Mạn", "ROM"),
Option("Thể Thao", "SPO"),
Option("Học Đường", "SCL"),
Option("Lịch Sử", "HIS"),
Option("Kinh Dị", "HOR"),
Option("Siêu Nhiên", "SUN"),
Option("Bi Kịch", "TRA"),
Option("Trùng Sinh", "RED"),
Option("Game", "GAM"),
Option("Viễn Tưởng", "FTS"),
Option("Khoa Học", "SCF"),
Option("Truyện Màu", "COI"),
Option("Người Lớn", "ADU"),
Option("BoyLove", "BBL"),
Option("Hầm Ngục", "DUN"),
Option("Săn Bắn", "HUNT"),
Option("Ngôn Từ Nhạy Cảm", "NTNC"),
Option("Doujinshi", "DOU"),
Option("Bạo Lực", "BLM"),
Option("Ngôn Tình", "NTT"),
Option("Nữ Cường", "NCT"),
Option("Gender Bender", "GDB"),
Option("Murim", "MRR"),
Option("Leo Tháp", "LTT"),
Option("Nấu Ăn", "COO"),
)

View File

@ -0,0 +1,138 @@
package eu.kanade.tachiyomi.extension.vi.goctruyentranhvui
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
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.parseAs
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class GocTruyenTranhVui : HttpSource() {
override val lang = "vi"
override val baseUrl = "https://goctruyentranhvui17.com"
override val name = "Goc Truyen Tranh Vui"
private val apiUrl = "$baseUrl/api/v2"
override val supportsLatest: Boolean = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(3)
.build()
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request = GET(
apiUrl.toHttpUrl().newBuilder().apply {
addPathSegments("home/filter")
addQueryParameter("p", (page - 1).toString())
addQueryParameter("value", "recommend")
}.build(),
headers,
)
override fun popularMangaParse(response: Response): MangasPage {
val res = response.parseAs<ResultDto<ListingDto>>()
val hasNextPage = res.result.next
return MangasPage(res.result.data.map { it.toSManga(baseUrl) }, hasNextPage)
}
override fun latestUpdatesRequest(page: Int): Request = GET(
"$apiUrl/search?p=${page - 1}&orders%5B%5D=recentDate",
headers,
)
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
override fun getMangaUrl(manga: SManga) = "$baseUrl/truyen/${manga.url.substringAfter(':')}"
override fun chapterListRequest(manga: SManga): Request {
val mangaId = manga.url.substringBefore(':')
val slug = manga.url.substringAfter(':')
return GET("$baseUrl/api/comic/$mangaId/chapter?limit=-1#$slug", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val slug = response.request.url.fragment!!
val chapterJson = response.parseAs<ResultDto<ChapterListDto>>()
return chapterJson.result.chapters.map { it.toSChapter(slug) }
}
override fun mangaDetailsRequest(manga: SManga) = GET(getMangaUrl(manga), headers)
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val document = response.asJsoup()
title = document.selectFirst(".v-card-title")!!.text()
genre = document.select(".group-content > .v-chip-link").joinToString { it.text() }
thumbnail_url = document.selectFirst("img.image")!!.absUrl("src")
status = parseStatus(document.selectFirst(".mb-1:contains(Trạng thái:) span")!!.text())
author = document.selectFirst(".mb-1:contains(Tác giả:) span")!!.text()
description = document.selectFirst(".v-card-text")!!.text()
}
private fun parseStatus(status: String?) = when {
status == null -> SManga.UNKNOWN
status.contains("Đang thực hiện", ignoreCase = true) -> SManga.ONGOING
status.contains("Hoàn thành", ignoreCase = true) -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun pageListParse(response: Response): List<Page> {
val html = response.body.string()
val pattern = Regex("chapterJson:\\s*`(.*?)`")
val match = pattern.find(html) ?: throw Exception("Không tìm thấy Json") // find json
val jsonPage = match.groups[1]!!.value
if (jsonPage.isEmpty()) throw Exception("Không có nội dung. Hãy đăng nhập trong WebView") // loginRequired
val result = jsonPage.parseAs<ImageListWrapper>()
val imageList = result.body.result.data
return imageList.mapIndexed { i, url ->
val finalUrl = if (url.startsWith("/image/")) {
baseUrl + url
} else {
url
}
Page(i, imageUrl = finalUrl)
}
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = apiUrl.toHttpUrl().newBuilder().apply {
addPathSegments("search")
addQueryParameter("p", (page - 1).toString())
addQueryParameter("searchValue", query)
for (filter in filters) {
when (filter) {
is FilterGroup ->
for (checkbox in filter.state) {
if (checkbox.state) addQueryParameter(filter.query, checkbox.id)
}
else -> {}
}
}
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
override fun getFilterList() = FilterList(
StatusList(getStatusList()),
SortByList(getSortByList()),
GenreList(getGenreList()),
)
}