Created the Tencent Comics extension (#8524)
This commit is contained in:
		
							parent
							
								
									7ab57f5c51
								
							
						
					
					
						commit
						1ff375ebf6
					
				
							
								
								
									
										27
									
								
								src/zh/tencentcomics/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/zh/tencentcomics/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | <?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=".zh.tencentcomics.TencentComicsUrlActivity" | ||||||
|  |             android:excludeFromRecents="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="m.ac.qq.com" | ||||||
|  |                     android:pathPattern="/comic/index/id/..*" | ||||||
|  |                     android:scheme="https" /> | ||||||
|  |                 <data | ||||||
|  |                     android:host="*ac.qq.com" | ||||||
|  |                     android:pathPattern="/Comic/comicInfo/id/..*" | ||||||
|  |                     android:scheme="https" /> | ||||||
|  | 
 | ||||||
|  |             </intent-filter> | ||||||
|  |         </activity> | ||||||
|  |     </application> | ||||||
|  | </manifest> | ||||||
							
								
								
									
										12
									
								
								src/zh/tencentcomics/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/zh/tencentcomics/build.gradle
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | apply plugin: 'com.android.application' | ||||||
|  | apply plugin: 'kotlin-android' | ||||||
|  | 
 | ||||||
|  | ext { | ||||||
|  |     extName = 'Tencent Comics (ac.qq.com)' | ||||||
|  |     pkgNameSuffix = 'zh.tencentcomics' | ||||||
|  |     extClass = '.TencentComics' | ||||||
|  |     extVersionCode = 1 | ||||||
|  |     libVersion = '1.2' | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | apply from: "$rootDir/common.gradle" | ||||||
							
								
								
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 3.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 9.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 20 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 32 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/web_hi_res_512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/zh/tencentcomics/res/web_hi_res_512.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 239 KiB | 
| @ -0,0 +1,298 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.zh.tencentcomics | ||||||
|  | 
 | ||||||
|  | import android.util.Base64 | ||||||
|  | import com.squareup.duktape.Duktape | ||||||
|  | import eu.kanade.tachiyomi.network.GET | ||||||
|  | import eu.kanade.tachiyomi.network.asObservableSuccess | ||||||
|  | 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 okhttp3.OkHttpClient | ||||||
|  | import okhttp3.Request | ||||||
|  | import okhttp3.Response | ||||||
|  | import org.json.JSONObject | ||||||
|  | import org.jsoup.nodes.Document | ||||||
|  | import org.jsoup.nodes.Element | ||||||
|  | import rx.Observable | ||||||
|  | import kotlin.collections.ArrayList | ||||||
|  | 
 | ||||||
|  | class TencentComics : ParsedHttpSource() { | ||||||
|  | 
 | ||||||
|  |     override val name = "Tencent Comics (ac.qq.com)" | ||||||
|  |     // its easier to parse the mobile version of the website | ||||||
|  |     override val baseUrl = "https://m.ac.qq.com" | ||||||
|  | 
 | ||||||
|  |     private val desktopUrl = "https://ac.qq.com" | ||||||
|  | 
 | ||||||
|  |     override val lang = "zh" | ||||||
|  | 
 | ||||||
|  |     override val supportsLatest = true | ||||||
|  | 
 | ||||||
|  |     override val client: OkHttpClient = network.cloudflareClient | ||||||
|  | 
 | ||||||
|  |     override fun chapterListSelector(): String = "ul.chapter-wrap-list.reverse > li > a" | ||||||
|  | 
 | ||||||
|  |     override fun chapterFromElement(element: Element): SChapter { | ||||||
|  |         return SChapter.create().apply { | ||||||
|  |             url = element.attr("href").trim() | ||||||
|  |             name = element.text().trim() | ||||||
|  |             chapter_number = element.attr("data-seq").toFloat() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaSelector(): String = "ul.ret-search-list.clearfix > li" | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaFromElement(element: Element): SManga { | ||||||
|  |         return SManga.create().apply { | ||||||
|  |             url = "/comic/index/" + element.select("div > a").attr("href").substringAfter("/Comic/comicInfo/") | ||||||
|  |             title = element.select("div > a").attr("title").trim() | ||||||
|  |             thumbnail_url = element.select("div > a > img").attr("data-original") | ||||||
|  |             author = element.select("div > p.ret-works-author").text().trim() | ||||||
|  |             description = element.select("div > p.ret-works-decs").text().trim() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.") | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaRequest(page: Int): Request = GET("$desktopUrl/Comic/all/search/hot/page/$page)", headers) | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaParse(response: Response): MangasPage { | ||||||
|  |         val document = response.asJsoup() | ||||||
|  |         val mangas = document.select(popularMangaSelector()).map { element -> | ||||||
|  |             popularMangaFromElement(element) | ||||||
|  |         } | ||||||
|  |         // next page buttons do not exist | ||||||
|  |         // even if the total searches happen to be 12 the website fills the next page anyway | ||||||
|  |         return MangasPage(mangas, mangas.size == 12) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesSelector(): String = "ul.ret-search-list.clearfix > li" | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesFromElement(element: Element): SManga { | ||||||
|  |         return popularMangaFromElement(element) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.") | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesRequest(page: Int): Request = GET("$desktopUrl/Comic/all/search/time/page/$page)", headers) | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesParse(response: Response): MangasPage { | ||||||
|  |         return popularMangaParse(response) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // desktop version of the site has more info | ||||||
|  |     override fun mangaDetailsRequest(manga: SManga): Request = GET("$desktopUrl/Comic/comicInfo/" + manga.url.substringAfter("/index/"), headers) | ||||||
|  | 
 | ||||||
|  |     override fun mangaDetailsParse(document: Document): SManga { | ||||||
|  |         return SManga.create().apply { | ||||||
|  |             thumbnail_url = document.select("div.works-cover.ui-left > a > img").attr("src") | ||||||
|  |             title = document.select("h2.works-intro-title.ui-left > strong").text().trim() | ||||||
|  |             description = document.select("p.works-intro-short").text().trim() | ||||||
|  |             author = document.select("p.works-intro-digi > span > em").text().trim() | ||||||
|  |             status = when (document.select("label.works-intro-status").text().trim()) { | ||||||
|  |                 "连载中" -> SManga.ONGOING | ||||||
|  |                 "已完结" -> SManga.COMPLETED | ||||||
|  |                 "連載中" -> SManga.ONGOING | ||||||
|  |                 "已完結" -> SManga.COMPLETED | ||||||
|  |                 else -> SManga.UNKNOWN | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // convert url to desktop since some chapters are blocked on mobile | ||||||
|  |     override fun pageListRequest(chapter: SChapter): Request = GET("$desktopUrl/ComicView/" + chapter.url.substringAfter("/chapter/"), headers) | ||||||
|  | 
 | ||||||
|  |     private val jsDecodeFunction = """ | ||||||
|  |         raw = raw.split(''); | ||||||
|  |         nonce = nonce.match(/\d+[a-zA-Z]+/g); | ||||||
|  |         var len = nonce.length; | ||||||
|  |         while (len--) { | ||||||
|  |             var offset = parseInt(nonce[len]) & 255; | ||||||
|  |             var noise = nonce[len].replace(/\d+/g, ''); | ||||||
|  |             raw.splice(offset, noise.length); | ||||||
|  |         } | ||||||
|  |         raw.join(''); | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     override fun pageListParse(document: Document): List<Page> { | ||||||
|  |         val duktape = Duktape.create() | ||||||
|  |         val pages = ArrayList<Page>() | ||||||
|  |         var html = document.html() | ||||||
|  | 
 | ||||||
|  |         // Sometimes the nonce has commands that are unrunnable, just reload and hope | ||||||
|  |         var nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("</script>").trim() | ||||||
|  | 
 | ||||||
|  |         while (nonce.contains("document") || nonce.contains("window")) { | ||||||
|  |             html = client.newCall(GET(desktopUrl + document.select("li.now-reading > a").attr("href"), headers)).execute().body!!.string() | ||||||
|  |             nonce = html.substringAfterLast("window[").substringAfter("] = ").substringBefore("</script>").trim() | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val raw = html.substringAfterLast("var DATA =").substringBefore("PRELOAD_NUM").trim().replace(Regex("^\'|\',$"), "") | ||||||
|  |         val decodePrefix = "var raw = \"$raw\"; var nonce = $nonce" | ||||||
|  |         val full = duktape.evaluate(decodePrefix + jsDecodeFunction).toString() | ||||||
|  |         val chapterData = JSONObject(String(Base64.decode(full, Base64.DEFAULT))) | ||||||
|  | 
 | ||||||
|  |         if (!chapterData.getJSONObject("chapter").getBoolean("canRead")) throw Exception("[此章节为付费内容]") | ||||||
|  | 
 | ||||||
|  |         val pictures = chapterData.getJSONArray("picture") | ||||||
|  |         for (i in 0 until pictures.length()) { | ||||||
|  |             pages.add(Page(i, "", pictures.getJSONObject(i).getString("url"))) | ||||||
|  |         } | ||||||
|  |         return pages | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun imageUrlParse(document: Document) = throw UnsupportedOperationException("Not used.") | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaSelector() = "ul > li.comic-item > a" | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaFromElement(element: Element): SManga { | ||||||
|  |         return SManga.create().apply { | ||||||
|  |             url = element.attr("href") | ||||||
|  |             title = element.select("div > strong").text().trim() | ||||||
|  |             thumbnail_url = element.select("div > img").attr("src") | ||||||
|  |             description = element.select("div > small.comic-desc").text().trim() | ||||||
|  |             genre = element.select("div > small.comic-tag").text().trim().replace(" ", ", ") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaNextPageSelector() = throw java.lang.UnsupportedOperationException("Not used.") | ||||||
|  | 
 | ||||||
|  |     override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { | ||||||
|  |         return if (query.startsWith(ID_SEARCH_PREFIX)) { | ||||||
|  |             val id = query.removePrefix(ID_SEARCH_PREFIX) | ||||||
|  |             client.newCall(searchMangaByIdRequest(id)) | ||||||
|  |                 .asObservableSuccess() | ||||||
|  |                 .map { response -> searchMangaByIdParse(response, id) } | ||||||
|  |         } else { | ||||||
|  |             super.fetchSearchManga(page, query, filters) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/comic/index/id/$id", headers) | ||||||
|  | 
 | ||||||
|  |     private fun searchMangaByIdParse(response: Response, id: String): MangasPage { | ||||||
|  |         val sManga = mangaDetailsParse(response) | ||||||
|  |         sManga.url = "/comic/index/id/$id" | ||||||
|  |         return MangasPage(listOf(sManga), false) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||||
|  |         // impossible to search a manga use the filters | ||||||
|  |         return if (query.isNotEmpty()) { | ||||||
|  |             GET("$baseUrl/search/result?word=$query&page=$page", headers) | ||||||
|  |         } else { | ||||||
|  |             lateinit var genre: String | ||||||
|  |             lateinit var status: String | ||||||
|  |             lateinit var popularity: String | ||||||
|  |             lateinit var vip: String | ||||||
|  |             filters.forEach { filter -> | ||||||
|  |                 when (filter) { | ||||||
|  |                     is GenreFilter -> { | ||||||
|  |                         genre = filter.toUriPart() | ||||||
|  |                         if (genre.isNotEmpty()) genre = "theme/$genre/" | ||||||
|  |                     } | ||||||
|  |                     is StatusFilter -> { | ||||||
|  |                         status = filter.toUriPart() | ||||||
|  |                     } | ||||||
|  |                     is PopularityFilter -> { | ||||||
|  |                         popularity = filter.toUriPart() | ||||||
|  |                     } | ||||||
|  |                     is VipFilter -> { | ||||||
|  |                         vip = filter.toUriPart() | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             GET("$desktopUrl/Comic/all/$genre${status}search/$popularity${vip}page/$page") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaParse(response: Response): MangasPage { | ||||||
|  |         val document = response.asJsoup() | ||||||
|  |         // Normal search | ||||||
|  |         return if (response.request.url.host.contains("m.ac.qq.com")) { | ||||||
|  |             val mangas = document.select(searchMangaSelector()).map { element -> | ||||||
|  |                 searchMangaFromElement(element) | ||||||
|  |             } | ||||||
|  |             MangasPage(mangas, mangas.size == 10) | ||||||
|  |             // Filter search | ||||||
|  |         } else { | ||||||
|  |             val mangas = document.select(popularMangaSelector()).map { element -> | ||||||
|  |                 popularMangaFromElement(element) | ||||||
|  |             } | ||||||
|  |             // next page buttons do not exist | ||||||
|  |             // even if the total searches happen to be 12 the website fills the next page anyway | ||||||
|  |             MangasPage(mangas, mangas.size == 12) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun getFilterList() = FilterList( | ||||||
|  |         Filter.Header("注意:不影響按標題搜索"), | ||||||
|  |         PopularityFilter(), | ||||||
|  |         VipFilter(), | ||||||
|  |         StatusFilter(), | ||||||
|  |         GenreFilter() | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) : | ||||||
|  |         Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) { | ||||||
|  |         fun toUriPart() = vals[state].second | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private class PopularityFilter : UriPartFilter( | ||||||
|  |         "热门人气/更新时间", | ||||||
|  |         arrayOf( | ||||||
|  |             Pair("热门人气", "hot/"), | ||||||
|  |             Pair("更新时间", "time/") | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     private class VipFilter : UriPartFilter( | ||||||
|  |         "属性", | ||||||
|  |         arrayOf( | ||||||
|  |             Pair("全部", ""), | ||||||
|  |             Pair("付费", "vip/2/"), | ||||||
|  |             Pair("免费", "vip/1/") | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     private class StatusFilter : UriPartFilter( | ||||||
|  |         "进度", | ||||||
|  |         arrayOf( | ||||||
|  |             Pair("全部", ""), | ||||||
|  |             Pair("连载中", "finish/1/"), | ||||||
|  |             Pair("已完结", "finish/2/") | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     private class GenreFilter : UriPartFilter( | ||||||
|  |         "标签", | ||||||
|  |         arrayOf( | ||||||
|  |             Pair("全部", ""), | ||||||
|  |             Pair("恋爱", "105"), | ||||||
|  |             Pair("玄幻", "101"), | ||||||
|  |             Pair("异能", "103"), | ||||||
|  |             Pair("恐怖", "110"), | ||||||
|  |             Pair("剧情", "106"), | ||||||
|  |             Pair("科幻", "108"), | ||||||
|  |             Pair("悬疑", "112"), | ||||||
|  |             Pair("奇幻", "102"), | ||||||
|  |             Pair("冒险", "104"), | ||||||
|  |             Pair("犯罪", "111"), | ||||||
|  |             Pair("动作", "109"), | ||||||
|  |             Pair("日常", "113"), | ||||||
|  |             Pair("竞技", "114"), | ||||||
|  |             Pair("武侠", "115"), | ||||||
|  |             Pair("历史", "116"), | ||||||
|  |             Pair("战争", "117") | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         const val ID_SEARCH_PREFIX = "id:" | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,34 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.zh.tencentcomics | ||||||
|  | 
 | ||||||
|  | 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 TencentComicsUrlActivity : Activity() { | ||||||
|  |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onCreate(savedInstanceState) | ||||||
|  |         val pathSegments = intent?.data?.pathSegments | ||||||
|  |         if (pathSegments != null && pathSegments.size > 3) { | ||||||
|  |             val id = pathSegments[3] | ||||||
|  |             val mainIntent = Intent().apply { | ||||||
|  |                 action = "eu.kanade.tachiyomi.SEARCH" | ||||||
|  |                 putExtra("query", "${TencentComics.ID_SEARCH_PREFIX}$id") | ||||||
|  |                 putExtra("filter", packageName) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             try { | ||||||
|  |                 startActivity(mainIntent) | ||||||
|  |             } catch (e: ActivityNotFoundException) { | ||||||
|  |                 Log.e("TencentUrlActivity", e.toString()) | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             Log.e("TencentUrlActivity", "could not parse uri from intent $intent") | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         finish() | ||||||
|  |         exitProcess(0) | ||||||
|  |     } | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 ShadesOfRay
						ShadesOfRay