diff --git a/src/vi/blogtruyen/AndroidManifest.xml b/src/vi/blogtruyen/AndroidManifest.xml new file mode 100644 index 000000000..12dcbe5bf --- /dev/null +++ b/src/vi/blogtruyen/AndroidManifest.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="eu.kanade.tachiyomi.extension"> + <application> + <activity android:name=".vi.blogtruyen.BlogTruyenUrlActivity" + 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="blogtruyen.vn" /> + <data android:host="m.blogtruyen.vn" /> + <data android:scheme="https" /> + + <!-- + Try to ensure that the passed URL is a chapter `c{id}` or a manga `{id}`, with `id` + being a number. + --> + <data android:pathPattern="/c1.*/..*" /> + <data android:pathPattern="/c2.*/..*" /> + <data android:pathPattern="/c3.*/..*" /> + <data android:pathPattern="/c4.*/..*" /> + <data android:pathPattern="/c5.*/..*" /> + <data android:pathPattern="/c6.*/..*" /> + <data android:pathPattern="/c7.*/..*" /> + <data android:pathPattern="/c8.*/..*" /> + <data android:pathPattern="/c9.*/..*" /> + + <data android:pathPattern="/1.*/..*" /> + <data android:pathPattern="/2.*/..*" /> + <data android:pathPattern="/3.*/..*" /> + <data android:pathPattern="/4.*/..*" /> + <data android:pathPattern="/5.*/..*" /> + <data android:pathPattern="/6.*/..*" /> + <data android:pathPattern="/7.*/..*" /> + <data android:pathPattern="/8.*/..*" /> + <data android:pathPattern="/9.*/..*" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/src/vi/blogtruyen/build.gradle b/src/vi/blogtruyen/build.gradle new file mode 100644 index 000000000..6ef7294ba --- /dev/null +++ b/src/vi/blogtruyen/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + extName = 'BlogTruyen' + pkgNameSuffix = 'vi.blogtruyen' + extClass = '.BlogTruyen' + extVersionCode = 15 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/vi/blogtruyen/res/mipmap-hdpi/ic_launcher.png b/src/vi/blogtruyen/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..08acf9eee Binary files /dev/null and b/src/vi/blogtruyen/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/vi/blogtruyen/res/mipmap-mdpi/ic_launcher.png b/src/vi/blogtruyen/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..194bacf6f Binary files /dev/null and b/src/vi/blogtruyen/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/vi/blogtruyen/res/mipmap-xhdpi/ic_launcher.png b/src/vi/blogtruyen/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..67f7bc7e5 Binary files /dev/null and b/src/vi/blogtruyen/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/vi/blogtruyen/res/mipmap-xxhdpi/ic_launcher.png b/src/vi/blogtruyen/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..14bf7024a Binary files /dev/null and b/src/vi/blogtruyen/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/vi/blogtruyen/res/mipmap-xxxhdpi/ic_launcher.png b/src/vi/blogtruyen/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..208871d0b Binary files /dev/null and b/src/vi/blogtruyen/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/vi/blogtruyen/res/web_hi_res_512.png b/src/vi/blogtruyen/res/web_hi_res_512.png new file mode 100644 index 000000000..7ccf314cf Binary files /dev/null and b/src/vi/blogtruyen/res/web_hi_res_512.png differ diff --git a/src/vi/blogtruyen/src/eu/kanade/tachiyomi/extension/vi/blogtruyen/BlogTruyen.kt b/src/vi/blogtruyen/src/eu/kanade/tachiyomi/extension/vi/blogtruyen/BlogTruyen.kt new file mode 100644 index 000000000..262b66f5b --- /dev/null +++ b/src/vi/blogtruyen/src/eu/kanade/tachiyomi/extension/vi/blogtruyen/BlogTruyen.kt @@ -0,0 +1,330 @@ +package eu.kanade.tachiyomi.extension.vi.blogtruyen + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +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.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.FormBody +import okhttp3.Headers +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.SimpleDateFormat +import java.util.Locale + +class BlogTruyen : ParsedHttpSource() { + + override val name = "BlogTruyen" + + override val baseUrl = "https://blogtruyen.vn" + + override val lang = "vi" + + override val supportsLatest = false + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .rateLimit(1) + .build() + + private val json: Json by injectLazy() + + private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.ENGLISH) + + private lateinit var directory: List<Element> + + override fun headersBuilder(): Headers.Builder = + super.headersBuilder().add("Referer", "$baseUrl/") + + override fun fetchPopularManga(page: Int): Observable<MangasPage> { + return if (page == 1) { + client.newCall(popularMangaRequest(page)) + .asObservableSuccess() + .map { response -> + popularMangaParse(response) + } + } else { + Observable.just(parseDirectory(page)) + } + } + + override fun popularMangaRequest(page: Int): Request = + GET("https://forum.blogtruyen.vn/anh-em-doc-tam/anh-em-doc-tam-75733") + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + directory = document.select(popularMangaSelector()) + return parseDirectory(1) + } + + private fun parseDirectory(page: Int): MangasPage { + val mangas = mutableListOf<SManga>() + val endRange = ((page * 24) - 1).let { if (it <= directory.lastIndex) it else directory.lastIndex } + + for (i in (((page - 1) * 24)..endRange)) { + mangas.add(popularMangaFromElement(directory[i])) + } + + return MangasPage(mangas, endRange < directory.lastIndex) + } + + override fun popularMangaSelector() = "div.topic-content div a" + + override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { + setUrlWithoutDomain(element.attr("href")) + title = element.text() + thumbnail_url = element.selectFirst("img")?.attr("src") + } + + override fun popularMangaNextPageSelector(): String? = null + + override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException("Unused") + + override fun latestUpdatesSelector(): String = throw UnsupportedOperationException("Unused") + + override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException("Unused") + + override fun latestUpdatesNextPageSelector(): String? = null + + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable<MangasPage> { + return when { + query.startsWith(PREFIX_ID_SEARCH) -> { + var id = query.removePrefix(PREFIX_ID_SEARCH).trim() + + // it's a chapter, resolve to manga ID + if (id.startsWith("c")) { + val document = client.newCall(GET("$baseUrl/$id", headers)).execute().asJsoup() + throwIfUnapprovedManga(document) + + id = document.selectFirst(".breadcrumbs a:last-child")!!.attr("href").removePrefix("/") + } + + fetchMangaDetails( + SManga.create().apply { + url = "/$id" + }, + ) + .map { MangasPage(listOf(it.apply { url = "/$id" }), false) } + } + page == 1 -> client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> + searchMangaParse(response, query, filters) + } + else -> Observable.just(parseDirectory(page)) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + popularMangaRequest(1) + + private fun searchMangaParse(response: Response, query: String, filters: FilterList): MangasPage { + val trimmedQuery = query.trim() + + directory = response.asJsoup().select(popularMangaSelector()).filter { it -> + it.text().contains(trimmedQuery, ignoreCase = true) + } + + return parseDirectory(1) + } + + override fun searchMangaSelector(): String = throw UnsupportedOperationException("Unused") + + override fun searchMangaFromElement(element: Element): SManga = + throw UnsupportedOperationException("Unused") + + override fun searchMangaNextPageSelector(): String? = null + + private fun getMangaTitle(document: Document) = document.selectFirst(".entry-title a")!! + .attr("title") + .replaceFirst("truyện tranh", "", false) + .trim() + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + throwIfUnapprovedManga(document) + + val anchor = document.selectFirst(".entry-title a")!! + setUrlWithoutDomain(anchor.attr("href")) + title = getMangaTitle(document) + + thumbnail_url = document.select(".thumbnail img").attr("abs:src") + author = document.select("a[href*=tac-gia]").joinToString { it.text() } + genre = document.select("span.category a").joinToString { it.text() } + status = parseStatus( + document.select("span.color-red:not(.bold)").text(), + ) + + description = StringBuilder().apply { + // the actual synopsis + val synopsisBlock = document.selectFirst(".manga-detail .detail .content")!! + + // replace the facebook blockquote in synopsis with the link (if there is one) + val fbElement = synopsisBlock.selectFirst(".fb-page, .fb-group") + if (fbElement != null) { + val fbLink = fbElement.attr("data-href") + + val node = document.createElement("p") + node.appendText(fbLink) + + fbElement.replaceWith(node) + } + appendLine(synopsisBlock.textWithNewlines().trim()) + appendLine() + + // other metadata + document.select(".description p").forEach { + val text = it.text() + if (text.contains("Thể loại") || + text.contains("Tác giả") || + text.isBlank() + ) { + return@forEach + } + + if (text.contains("Trạng thái")) { + appendLine(text.substringBefore("Trạng thái").trim()) + return@forEach + } + + if (text.contains("Nguồn") || + text.contains("Tham gia update") || + text.contains("Nhóm dịch") + ) { + val key = text.substringBefore(":") + val value = it.select("a").joinToString { el -> el.text() } + appendLine("$key: $value") + return@forEach + } + + it.select("a, span").append("\\n") + appendLine(it.text().replace("\\n", "\n").replace("\n ", "\n").trim()) + } + }.toString().trim() + } + + private fun Element.textWithNewlines() = run { + select("p").prepend("\\n") + select("br").prepend("\\n") + text().replace("\\n", "\n").replace("\n ", "\n") + } + + private fun parseStatus(status: String) = when { + status.contains("Đang tiến hành") -> SManga.ONGOING + status.contains("Đã hoàn thành") -> SManga.COMPLETED + status.contains("Tạm ngưng") -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + + override fun chapterListParse(response: Response): List<SChapter> { + val document = response.asJsoup() + + throwIfUnapprovedManga(document) + + val title = getMangaTitle(document) + return document.select(chapterListSelector()).map { chapterFromElement(it, title) } + } + + override fun chapterListSelector() = "div.list-wrap > p" + + override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException("Not used") + + private fun chapterFromElement(element: Element, title: String): SChapter = SChapter.create().apply { + val anchor = element.select("span > a").first()!! + + setUrlWithoutDomain(anchor.attr("href")) + name = anchor.attr("title").replace(title, "", true).trim() + date_upload = runCatching { + dateFormat.parse( + element.selectFirst("span.publishedDate")!!.text(), + )?.time + }.getOrNull() ?: 0L + } + + private fun countViewRequest(mangaId: String, chapterId: String): Request = POST( + "$baseUrl/Chapter/UpdateView", + headers, + FormBody.Builder() + .add("mangaId", mangaId) + .add("chapterId", chapterId) + .build(), + ) + + private fun countView(document: Document) { + val mangaId = document.getElementById("MangaId")!!.attr("value") + val chapterId = document.getElementById("ChapterId")!!.attr("value") + runCatching { + client.newCall(countViewRequest(mangaId, chapterId)).execute().close() + } + } + + override fun pageListParse(document: Document): List<Page> { + throwIfUnapprovedManga(document) + + val pages = mutableListOf<Page>() + + document.select("#content > img").forEachIndexed { i, e -> + pages.add(Page(i, imageUrl = e.attr("abs:src"))) + } + + // Some chapters use js script to render images + document.select("#content > script:containsData(listImageCaption)").lastOrNull() + ?.let { script -> + val imagesStr = script.data().substringBefore(";").substringAfterLast("=").trim() + val imageArr = json.parseToJsonElement(imagesStr).jsonArray + imageArr.forEach { + val imageUrl = it.jsonObject["url"]!!.jsonPrimitive.content + pages.add(Page(pages.size, imageUrl = imageUrl)) + } + } + + countView(document) + return pages + } + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used") + + private fun isPageUnblocked(document: Document): Boolean { + val anchor = document.selectFirst("center b a")?.attr("href") ?: return true + val img = document.selectFirst("center img")?.attr("src") ?: return true + + val content = document.body().text() + + if (content != UNDERGOING_CHECK || anchor != APPROVED_MANGA_POST || img != DONATION_IMAGE) { + return true + } + + return false + } + + private fun throwIfUnapprovedManga(document: Document) { + if (!isPageUnblocked(document)) { + throw Exception("Truyện chưa được kiểm duyệt!") + } + } + + companion object { + const val PREFIX_ID_SEARCH = "id:" + + const val UNDERGOING_CHECK = "Website đang rà soát lại nội dung. Anh em đọc tạm một số truyện nhẹ nhàng lành mạnh đã được kiểm duyệt TẠI ĐÂY Website đang trong giai đoạn khó khăn, nhưng hãy donate chỉ khi bạn sẵn lòng và vui vẻ thôi nhé \uD83D\uDC96" + const val APPROVED_MANGA_POST = "https://forum.blogtruyen.vn/anh-em-doc-tam/anh-em-doc-tam-75733" + const val DONATION_IMAGE = "https://blogtruyen.vn/xin-donate.png" + } +} diff --git a/src/vi/blogtruyen/src/eu/kanade/tachiyomi/extension/vi/blogtruyen/BlogTruyenUrlActivity.kt b/src/vi/blogtruyen/src/eu/kanade/tachiyomi/extension/vi/blogtruyen/BlogTruyenUrlActivity.kt new file mode 100644 index 000000000..5ce6bd1f6 --- /dev/null +++ b/src/vi/blogtruyen/src/eu/kanade/tachiyomi/extension/vi/blogtruyen/BlogTruyenUrlActivity.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.extension.vi.blogtruyen + +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 BlogTruyenUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + try { + startActivity( + Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${BlogTruyen.PREFIX_ID_SEARCH}${pathSegments[0]}/${pathSegments[1]}") + putExtra("filter", packageName) + }, + ) + } catch (e: ActivityNotFoundException) { + Log.e("BlogTruyenUrlActivity", e.toString()) + } + } else { + Log.e("BlogTruyenUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +}