205 Commits

Author SHA1 Message Date
089191d6f1 Merge branch 'master' into source/neox 2023-05-13 14:55:01 +03:00
20ad9d0472 [ALlHentai] New parser 2023-05-13 14:40:15 +03:00
96b9ac36f3 [Bentomanga] Fixes 2023-05-06 17:28:34 +03:00
65170c0fe9 [Mangadex] Fix chapters numbers 2023-05-01 09:03:11 +03:00
3e349d3db3 [Ninemanga] Fix redirects 2023-04-28 17:21:19 +03:00
b2675c3cac Fix @MangaSources annotation 2023-04-28 16:17:47 +03:00
7414cf4a4e Add @JvmField on models fields 2023-04-28 15:43:13 +03:00
f43c482539 [Honeymanga] Refactoring 2023-04-28 15:31:03 +03:00
f0fbbf4333 Merge pull request #169 from CakesTwix/master
[New source] HoneyManga
2023-04-28 15:20:30 +03:00
0d03c68c8b [Manhwaland] Fix list url 2023-04-28 15:20:00 +03:00
CakesTwix
b4a4c86049 [HoneyManga] Impl. filter by genres
and fix cover url
2023-04-26 16:35:42 +03:00
306d46ea93 [Grouple] Fix pages url extraction 2023-04-26 09:24:39 +03:00
5b94badfc2 [MangaRead] Fix list parsing 2023-04-25 08:13:32 +03:00
9adc0c5358 [Bentomanga] New parser 2023-04-24 19:34:29 +03:00
66adc65a96 [JapScan] New parser 2023-04-24 16:51:53 +03:00
8def9b5446 Improve network error handling 2023-04-23 09:20:11 +03:00
e6511061a7 [Grouple] Fix server selecting 2023-04-20 18:07:20 +03:00
CakesTwix
647a694d91 [HoneyManga] throw IllegalArgumentException if search < 3 2023-04-19 20:20:37 +03:00
CakesTwix
e5ececa42d [HoneyManga] Update code, get(), CONTENT_ENCODING 2023-04-19 19:58:25 +03:00
c321faeaa9 Merge pull request #168 from VietAnh14/source/doujindesu
[DoujinDesu] New parser
2023-04-19 19:24:29 +03:00
vianh
3112b7937c [DoujinDesu] Apply suggesions from code review 2023-04-18 23:49:46 +07:00
CakesTwix
a3565abdc3 [HoneyManga] Improve code 2023-04-18 19:35:15 +03:00
CakesTwix
34d2e15302 [New source] HoneyManga 2023-04-18 18:55:43 +03:00
1b6d1456f3 Add support for POST with json body 2023-04-17 08:35:58 +03:00
vianh
b49aee3188 [DoujinDesu] New parser 2023-04-16 22:52:22 +07:00
749f682ef9 [Madara] Fix empty tags 2023-04-15 13:22:33 +03:00
1b269efaa4 Update readme 2023-04-15 13:02:26 +03:00
2db1a8d958 Remove broken parser 2023-04-15 12:59:29 +03:00
ece7d71e09 Update domains 2023-04-15 12:51:29 +03:00
129602ea70 [IsekaiScan] Fix chapters numbers #162 2023-04-15 12:44:17 +03:00
5e3fddb652 [Grouple] Fix relative page urls 2023-04-12 18:35:46 +03:00
2340100999 Fix false nsfw flags on some madara parsers #164 2023-04-06 18:49:19 +03:00
dc0151720e [IsekaiScanEu] Fix chapters order #162 2023-04-04 07:52:24 +03:00
36e88b2c0c [Remanga] Fix chapters numbers 2023-04-04 07:40:47 +03:00
35695904d1 [Remanga] Add Accept header to image requests 2023-03-30 18:52:11 +03:00
a3ffecc00f [IsekaiScan] Fix chapters parsing 2023-03-25 10:14:35 +02:00
cc418570d5 [Remanga] Fix mirror 2023-03-25 07:32:18 +02:00
481af22ae7 [Remanga] Add mirror 2023-03-24 18:02:31 +03:00
96b9dc8b5c [ComicK] Updated API 2023-03-24 16:05:24 +03:00
93f5f70d79 Remove referrer field from page model 2023-03-19 09:28:38 +02:00
fc53b19915 Add toString to models 2023-03-18 18:20:00 +02:00
413f4a2f10 Batch parsers fixes 2023-03-09 07:32:04 +02:00
e8d299782b [Tempestfansub] Update domain #159 2023-03-05 08:09:49 +02:00
489a4ab586 [NeatManga] Fix chapters loading 2023-03-05 08:09:49 +02:00
32fa3a89e2 Merge pull request #152 from OshekharO/patch-1 2023-03-02 09:58:35 +02:00
Saksham Shekher
f2614dd3d7 Fix 2023-02-26 23:03:43 +05:30
1093584202 [Grouple] Add UserAgent config 2023-02-25 18:36:05 +02:00
f62d024342 Fix Manga District & Hentai4free parsers #157 2023-02-25 17:10:41 +02:00
eea87d8607 Fix grouple pages parsing #156 2023-02-25 16:43:06 +02:00
f4c47b5b84 Merge pull request #155 from VietAnh14/source/truyentranhlh
[TruyentranhLH]: New source
2023-02-21 18:28:27 +02:00
vianh
fa78d5dd6f [TruyentranhLH]: New source 2023-02-21 00:47:53 +07:00
779c6664be Merge pull request #154 from OshekharO/patch-2
BatoTo: New Subdomain
2023-02-18 07:18:03 +02:00
Saksham Shekher
38efd7935a BatoTo: New Subdomain 2023-02-18 07:35:24 +05:30
Saksham Shekher
03f021377d Update README.md
some minor grammatical corrections
2023-02-16 23:17:31 +05:30
cf345d2d0c [Grouple] update UserAgent 2023-02-16 19:12:35 +02:00
fb3a3f49dd Merge pull request #150 from OshekharO/patch-1
Add: KumaPoi
2023-02-16 17:59:39 +02:00
f3b731114e [DesuMe] Genres in list 2023-02-16 17:52:27 +02:00
454b24ec88 [Grouple] Selecting the fastest pages server 2023-02-16 17:52:14 +02:00
Saksham Shekher
2c6c64fbe9 Add: ToonHunter 2023-02-14 12:46:10 +05:30
Saksham Shekher
a51d6d6485 Add: Hentai20 2023-02-14 12:37:01 +05:30
Saksham Shekher
0da57c041c Fix 2023-02-14 12:09:40 +05:30
Saksham Shekher
503573f388 Add: KomikLokal 2023-02-14 12:03:18 +05:30
Saksham Shekher
29cd1f1fdf Add: KumaPoi 2023-02-14 11:54:42 +05:30
Saksham Shekher
e51b33c74a Add: Toonily, Komiklab, KomikDewasa, KomiKav (#148)
* Add: Toonily

* Add: KOMIKLAB

* Update MangaReaderParser.kt

* Add: KomikDewasa

* Add: KomiKav
2023-02-09 20:56:04 +03:00
624a3dd991 Merge pull request #146 from KotatsuApp/feature/refactor2
Improve network requests processing
2023-02-08 19:55:51 +02:00
05d705ac03 Remove deprecated "getFavicon" method 2023-02-08 11:08:57 +02:00
1e1756aa17 Refactor web client 2023-02-08 11:01:20 +02:00
91109bf6dc Merge pull request #144 from OshekharO/patch-1
Add: KomikManga & Fixes
2023-02-08 10:50:18 +02:00
Saksham Shekher
7f852e793a Add: BakaMan
Thai Manga Provider
2023-02-08 14:16:15 +05:30
Saksham Shekher
aae3747925 Add: KomikIndo
Closes #145
2023-02-08 08:27:17 +05:30
Saksham Shekher
5e23d52e8a refactor: HachiManga 2023-02-07 10:40:03 +05:30
Saksham Shekher
3bc8013d2f Merge branch 'KotatsuApp:master' into patch-1 2023-02-07 10:20:55 +05:30
00abaea324 [Remanga] Fix headers 2023-02-06 18:57:00 +02:00
d14168e325 Make headers accessible 2023-02-06 18:54:50 +02:00
c3b1556816 Add headers to CloudFlareException 2023-02-06 17:50:44 +02:00
80a2a10e71 [Remanga] Fix headers 2023-02-06 17:30:17 +02:00
Saksham Shekher
029f554d6e Add: DragonTranslation 2023-02-06 14:37:30 +05:30
Saksham Shekher
841d8f05d9 Fix: MangaRosie, PianManga 2023-02-06 14:19:49 +05:30
Saksham Shekher
7253699529 Dead: ReadManwha 2023-02-06 14:18:04 +05:30
Saksham Shekher
5fe546c94d Dead: MangaTX OT
Removed as site is not being updated
2023-02-06 14:16:40 +05:30
Saksham Shekher
31c26f16ac Dead: IsekaiScan
Url changed to isekaiscan.to ( chapter not found in kotatsu )
2023-02-06 14:13:58 +05:30
Saksham Shekher
5eda6f96b4 Dead: ReadManhwa 2023-02-06 14:10:54 +05:30
Saksham Shekher
56a54d5eb4 Fix: NeatManga 2023-02-06 14:10:12 +05:30
Saksham Shekher
7a78a67740 Dead: MangaKik 2023-02-06 14:08:40 +05:30
Saksham Shekher
742533977f Dead: X2Manga 2023-02-06 14:06:39 +05:30
Saksham Shekher
2dedaea0ab Dead: KingManga 2023-02-06 14:05:36 +05:30
Saksham Shekher
cf7f55181f Dead: Manhwa 2023-02-06 14:03:26 +05:30
Saksham Shekher
f68f0009a2 Dead: ManhwaChill 2023-02-06 14:00:07 +05:30
Saksham Shekher
4799f54fd1 Dead: All Top Manga 2023-02-06 13:57:30 +05:30
Saksham Shekher
06ff8df2e4 Dead: Heroxia 2023-02-06 13:52:37 +05:30
Saksham Shekher
9b78944f9b Fix: ManhwaList 2023-02-06 13:49:31 +05:30
Saksham Shekher
fc2781c83a Add: KomikManga 2023-02-06 13:43:02 +05:30
Saksham Shekher
176437531d Add: CosmicScans (#142)
* Add: CosmicScans

* Fix info

* Rem: nsfw

* Mangasu url changed 

Cert issue needs to be fixed
2023-02-05 19:44:48 +03:00
c28e2a72d5 [Remanga] Bypass blocking 2023-02-05 10:09:53 +03:00
7f630184c0 [GroupLe] Fix pages loading 2023-01-29 20:54:52 +03:00
e5a6b82853 [Grouple] Fix chapter link extraction 2023-01-24 09:11:09 +02:00
e1d22b1110 [ExHentai] Option to hide suspicious content 2023-01-24 08:57:19 +02:00
918318252e Fix coroutine cancellation handling in runCatching 2023-01-24 08:03:51 +02:00
e153463c35 [Grouple] Fix authorization recognizing 2023-01-24 07:58:56 +02:00
cf00732023 Misc parsers fixes 2023-01-05 11:26:58 +02:00
ef838068ff [MangahaTachi] Change url (closes #128) 2023-01-01 20:37:04 +03:00
c4acb9725f [Lib] Fix auth part 2 2022-12-31 22:28:46 +03:00
04e219ff69 Remove accidentally duplicated parser 2022-12-31 19:41:36 +03:00
106a85aa80 [Lib] Authorization fixes 2022-12-31 19:03:06 +03:00
ad6e074cce [Asura Scans] New parser 2022-12-29 18:12:22 +03:00
e89f06a35b [KomikTap] New parser (closes #40) (?) 2022-12-29 11:09:25 +03:00
238cc7529a [Asura Scans (tr)] New parser (closes #55) 2022-12-28 23:26:26 +03:00
fb22eb5fac [Tempest Fan Sub] New parser (closes #56) 2022-12-28 23:10:17 +03:00
9ee1c21a67 [ExHentai] Fix pagination 2022-12-26 18:23:15 +02:00
c7c142f4a0 [Remanga] Show bought chapters 2022-12-26 17:46:15 +02:00
efd0394398 [MangaTale] New parser (closes #125) 2022-12-25 12:22:13 +03:00
0091a8280a Merge pull request #122 from VietAnh14/source/heroxia
[MangaReader] Fix chapters order
2022-12-14 08:27:47 +02:00
vianh
f9b0e82473 [MangaReader] Add new source 2022-12-11 00:59:53 +07:00
vianh
63676c21a8 [MangaReader] fix chapters order 2022-12-10 14:55:43 +07:00
add70b4790 [ExHentai] Fix pagination 2022-12-09 18:15:01 +02:00
d18f665556 Merge pull request #121 from VietAnh14/source/heroxia
[MangaReadTheme] Add some new sources using MangaReader theme
2022-12-09 08:18:46 +02:00
vianh
42253e43b3 Add more mangareader theme sources, fix cache tags 2022-12-07 00:25:22 +07:00
vianh
ad500a9db6 [MangaReadTheme] Add some new sources using MangaReader theme 2022-12-06 23:17:31 +07:00
ViAnh
2ab03cb668 [Manhwa18] Fix parse upload date (#115) 2022-11-26 19:37:11 +03:00
ViAnh
b78ddc6166 [NetTruyen] Update domain (#116) 2022-11-26 19:36:41 +03:00
1e49d4095b [Grouple] Fix pages parsing 2022-11-21 09:03:13 +02:00
bf8a1f3db2 [MangaInUa] Fix #104 2022-11-04 17:30:38 +03:00
321cc7529e Merge pull request #96 from VietAnh14/source/manhwa18
[Manhwa18] New source
2022-10-24 19:35:30 +03:00
ViAnh
7907b3126c Apply suggestions from code review
Co-authored-by: Koitharu <nvasya95@gmail.com>
2022-10-24 23:29:53 +07:00
vianh
ccf9af0e51 [Manhwa18] New source 2022-10-24 23:05:13 +07:00
b38b43edc0 Merge pull request #94 from VietAnh14/master
[BlogTruyen] Fix image src attr change
2022-10-24 11:59:42 +03:00
vianh
5d13c55dd5 [BlogTruyen] Fix image src attr change 2022-10-23 20:37:40 +07:00
a1441e7ed7 [MangaDex] Fix large chapters loading error 2022-10-20 09:56:00 +03:00
a62662d116 Fix some madara parsers 2022-10-19 13:37:49 +03:00
5cb953eb86 Small fixes 2022-10-10 16:27:34 +03:00
505ffcf405 Mangalink parser #66 2022-10-10 16:07:14 +03:00
a45f9bfc58 ReaperScansID parser #83 2022-10-10 15:16:40 +03:00
b1990c7918 Fixes 2022-10-01 13:43:09 +03:00
e8b801684f [MangaDex] Fix error on empty description 2022-09-29 14:25:41 +03:00
de0c12c078 Add contribution guidelines 2022-09-14 20:10:18 +03:00
eebb6f0c44 [NeoxScans] Add source #47 2022-09-11 14:43:08 +03:00
b3a9c5fcda Propagate deprecation from MangaParser to MangaSource 2022-09-07 13:57:00 +03:00
c73d34b04f [Madara] Fix date parsing 2022-09-07 13:45:15 +03:00
583b24854f [Remanga] Omit paid chapters 2022-09-07 13:15:51 +03:00
1c79b29845 Merge branch 'master' of github.com:KotatsuApp/kotatsu-parsers 2022-09-07 13:07:02 +03:00
11f19b1f76 Add asserts 2022-09-07 13:05:23 +03:00
c348d68983 Add deprecation annotation to some sources 2022-09-04 17:53:29 +03:00
6d7506cc1b [CloneManga] Add source 2022-09-04 17:52:54 +03:00
551a1d70ae [MangaInUa] Fix chapters list #43 2022-09-03 10:48:35 +03:00
7ea80b51e1 Merge pull request #46 from VietAnh14/source/nettruyen
[NetTruyen] New source
2022-09-02 16:42:24 +03:00
ca6d5c429c Merge pull request #45 from VietAnh14/source/blogtruyen
[BlogTruyen] New source
2022-09-02 16:42:10 +03:00
vianh
eb1bcac175 [NetTruyen] Synchronize tags with mutex, use WordSet as suggestion 2022-09-02 18:36:00 +07:00
ViAnh
704a063cb3 [NetTruyen] Apply suggestions from code review
Co-authored-by: Koitharu <nvasya95@gmail.com>
2022-09-02 17:44:36 +07:00
vianh
1577afc4a9 [BlogTruyen] Synchronize tags with mutex 2022-09-02 17:35:43 +07:00
ViAnh
aec404c0a1 Apply suggestions from code review
Co-authored-by: Koitharu <nvasya95@gmail.com>
2022-09-02 17:23:50 +07:00
vianh
1bad7eeb75 [NetTruyen] Update to use PagedMangaParser 2022-09-02 11:15:00 +07:00
vianh
e3612ef466 [BlogTruyen] Update to use PagedMangaParser 2022-09-02 10:57:28 +07:00
vianh
01cc33e309 [NetTruyen] New source 2022-09-02 00:44:54 +07:00
vianh
599f606aef Remove deprecated favicon url 2022-09-01 22:27:48 +07:00
vianh
c976e0e4e0 [BlogTruyen] New source 2022-09-01 22:17:31 +07:00
f112a06ab6 [IsekaiScanEu] Fix chapters loading #41 2022-08-17 16:45:18 +03:00
8709c3dd0c [Grouple] Fix rating parsing 2022-08-08 12:13:25 +03:00
e2308214a7 [MangaDex] Speed up large chapters list loading (hotfix) 2022-08-04 13:32:53 +03:00
baf6234a2a [MangaDex] Speed up large chapters list loading 2022-08-04 13:19:48 +03:00
85bfe42ddf [Tatakae Scans] Fix favicon 2022-07-30 17:11:08 +03:00
dac06ddd0b [Tatakae Scans] New source 2022-07-30 15:10:44 +03:00
b632571ebc Update readme 2022-07-28 10:04:07 +03:00
7588617316 New source: PrismaScans 2022-07-27 15:56:06 +03:00
dd6de64c44 New manga source: UnionManga 2022-07-27 15:56:06 +03:00
fadb06aabb [Remanga] Fix details parsing 2022-07-20 19:41:49 +03:00
c4abb758f3 Make jsoup dependency api 2022-07-20 18:19:07 +03:00
6495ddf277 Add NotFoundException for 404 response 2022-07-20 17:09:37 +03:00
30071709af Add base url to ParseException 2022-07-19 11:36:26 +03:00
37fda4bd9e Oops! 2022-07-14 17:00:15 +03:00
a1dc901439 Update issue templates 2022-07-14 16:58:54 +03:00
6af8cec134 Update dependencies 2022-07-14 14:35:03 +03:00
67f6af63f8 [Madara] New source Mangas Origines (fr) 2022-07-14 11:59:00 +03:00
be595af075 Paginator class to compute page number by offset 2022-07-14 11:17:55 +03:00
c5c84c8630 Merge branch 'feature/favicons' 2022-07-13 15:13:56 +03:00
abc7eca40b Rename parseFavicons to getFavicons 2022-07-13 15:13:46 +03:00
2d1907569b [MangaLib] Fix authorization required detection 2022-07-12 10:43:29 +03:00
cb7a23950a [BatoTo] Fix pages parsing 2022-07-12 10:38:25 +03:00
941cc61577 Ignore malformed domains while ops with cookies 2022-07-12 10:21:21 +03:00
330495556a Refactor extra source headers 2022-07-12 09:31:18 +03:00
358910bafe [NineManga] Fix favicons redirect 2022-07-12 08:58:55 +03:00
c6b9180013 Parse favicons from websites 2022-07-11 13:31:50 +03:00
1f5d02ddb0 Merge pull request #36 from OshekharO/patch-3
Update Madara5Parser.kt
2022-07-08 14:50:52 +03:00
Saksham Shekher
f04d626e21 Update Madara5Parser.kt 2022-07-08 16:13:49 +05:30
Saksham Shekher
973d8fae27 Update Madara5Parser.kt 2022-07-08 16:08:19 +05:30
26d951bc20 [Madara5] Fix cover urls 2022-07-08 11:58:47 +03:00
dfaa0f5685 Merge pull request #35 from OshekharO/patch-3
Update Madara5Parser.kt
2022-07-08 11:51:25 +03:00
dee3e3d554 Favicons fixes 2022-07-08 11:51:07 +03:00
bccb1a491f [YaoiLib] New parser 2022-07-08 11:38:56 +03:00
5be4fb1114 Fix chapters duplicates 2022-07-08 11:26:05 +03:00
Saksham Shekher
a7c2235290 Update Madara5Parser.kt 2022-07-07 16:08:31 +05:30
8c26f3c790 [Grouple] Fix author parsing 2022-07-07 09:58:21 +03:00
aa341d6cab [Grouple] Fix auth url 2022-07-07 09:24:57 +03:00
b8b493fd15 Assume that 0 is no rating too 2022-07-06 16:50:29 +03:00
da3b0ae0cf [Grouple] Authorization support 2022-07-05 13:25:26 +03:00
79cdd18682 [Multichan] Authorization support 2022-07-05 13:10:13 +03:00
9f75b90325 [Madara] Add Madara5 parser 2022-07-02 14:22:25 +03:00
e97992b923 Update dependencies 2022-07-02 11:17:00 +03:00
035062d4bb Generate html report for parsers test 2022-06-30 15:18:46 +03:00
57667cb2b9 No fail test workflow 2022-06-30 15:18:45 +03:00
55ddc14244 [Issue template] Update 2022-06-24 23:16:52 +03:00
Saksham Shekher
a3f19abfb6 [Madara] Add manga sources (#24)
* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

Have used " th " code for declaring Thai language.

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

* Update MadaraParser.kt

From jp to ja

* Update MadaraParser.kt

* Update MadaraParser.kt

* VINLOAD

* Update MadaraParser.kt

* Manhwa

* MANGAKIK

* Update MadaraParser.kt
2022-06-24 22:16:38 +03:00
c92f89f307 [Madara] Fix empty tag names 2022-06-24 13:24:44 +03:00
a9684533b2 Merge pull request #21 from OshekharO/patch-1
[Madara] Add MangaRosie
2022-06-24 11:51:25 +03:00
e6659fabb0 Merge pull request #22 from KotatsuApp/feature/ci
Add ci job to run parsers test
2022-06-24 11:43:12 +03:00
Saksham Shekher
c660f356c7 Update MadaraParser.kt 2022-06-24 10:56:42 +05:30
111 changed files with 6457 additions and 1414 deletions

View File

@@ -6,8 +6,8 @@ end_of_line = lf
indent_style = tab indent_style = tab
max_line_length = 120 max_line_length = 120
tab_width = 4 tab_width = 4
insert_final_newline = false insert_final_newline = true
disabled_rules=no-wildcard-imports,no-unused-imports disabled_rules = no-wildcard-imports, no-unused-imports
[{*.kt,*.kts}] [{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma = true

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: ⚠️Application issue - name: ⚠️ Application issue
url: https://github.com/nv95/Kotatsu/issues/new/choose url: https://github.com/KotatsuApp/Kotatsu/issues/new/choose
about: Issues and requests about the app itself should be opened in the Kotatsu repository instead about: Issues and requests about the app itself should be opened in the Kotatsu repository instead

View File

@@ -25,27 +25,7 @@ body:
2. Second step 2. Second step
3. Issue here 3. Issue here
validations: validations:
required: true required: false
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
placeholder: |
Example:
"This should happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
placeholder: |
Example:
"This happened instead..."
validations:
required: true
- type: input - type: input
id: kotatsu-version id: kotatsu-version
@@ -67,7 +47,7 @@ body:
placeholder: | placeholder: |
Example: "Android 12" Example: "Android 12"
validations: validations:
required: true required: false
- type: textarea - type: textarea
id: other-details id: other-details
@@ -80,15 +60,6 @@ body:
id: acknowledgements id: acknowledgements
attributes: attributes:
label: Acknowledgements label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/nv95/Kotatsu/issues/new/choose).
required: true
- label: I will fill out all of the requested information in this form.
required: true required: true

View File

@@ -25,15 +25,6 @@ body:
id: acknowledgements id: acknowledgements
attributes: attributes:
label: Acknowledgements label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/nv95/Kotatsu/issues/new/choose).
required: true
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true required: true

View File

@@ -41,15 +41,10 @@ body:
id: acknowledgements id: acknowledgements
attributes: attributes:
label: Acknowledgements label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true required: true
- label: I have written a title with source name.
required: true
- label: I have checked that the source does not already exist on the app. - label: I have checked that the source does not already exist on the app.
required: true required: true
- label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/nv95/kotatsu-parsers) and verified it does not appear in the code base. - label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/KotatsuApp/kotatsu-parsers) and verified it does not appear in the code base.
required: true
- label: I will fill out all of the requested information in this form.
required: true required: true

View File

@@ -1,19 +1,17 @@
name: Parsers test name: Parsers test
on: on:
workflow_dispatch:
pull_request: pull_request:
branches: [ "master" ]
paths: paths:
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/site' - 'src/main/kotlin/org/koitharu/kotatsu/parsers/site/**'
permissions: permissions:
contents: read contents: read
jobs: jobs:
build: build-and-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-java@v3 - uses: actions/setup-java@v3
@@ -21,8 +19,9 @@ jobs:
java-version: '11' java-version: '11'
distribution: 'temurin' distribution: 'temurin'
cache: 'gradle' cache: 'gradle'
- run: ./gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest" - run: ./gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest" || true
- run: ./gradlew generateTestsReport
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: Report name: Report
path: build/reports/tests/test path: build/test-results-html/TEST-org.koitharu.kotatsu.parsers.MangaParserTest.htm

3
.gitignore vendored
View File

@@ -75,4 +75,5 @@ build/
.idea/**/ktlint.xml .idea/**/ktlint.xml
.idea/codeStyles/ .idea/codeStyles/
src/test/resources/cookies.txt src/test/resources/cookies.txt
local.properties

6
.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.6.21" />
</component>
</project>

91
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,91 @@
# Contributing
The following is guide for creating a Kotatsu parsers. Thanks for taking the time to contribute!
## Prerequisites
Before you start, please note that the ability to use following technologies is **required**.
- Basic [Android development](https://developer.android.com/)
- [Kotlin](https://kotlinlang.org/)
- Web scraping ([JSoup](https://jsoup.org/)) or JSON API
### Tools
- [Android Studio](https://developer.android.com/studio)
- [IntelliJ IDEA](https://www.jetbrains.com/idea/) (Community edition is enough)
- Android device (or emulator)
Kotatsu parsers is not a part of Android application, but you can easily develop and test it directly inside an Android
application project and relocate it to the library project when done.
### Before you start
First, take a look at `kotatsu-parsers` project structure. Each parser is a single class that
extends `MangaParser` class and have a `MangaSourceParser` annotation.
Also pay attention on extensions in `util` package. For example, extensions from `Jsoup` file
should be used instead of existing JSoup functions because they have better nullability support
and improved error messages.
## Writing your parser
So, you want to create a parser, that will provide access to manga from a website.
First, you should explore a website for API availability.
If it does not contain any documentation about
API, [explore network requests](https://firefox-source-docs.mozilla.org/devtools-user/):
some websites use ajax.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/DesuMeParser.kt)
of Json API usage.
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/AnibelParser.kt)
of GraphQL API usage
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/MangaTownParser.kt)
of pure HTML parsing.
If website is based on some engine it is rationally to use common base class for this one (for example, Madara wordress
theme
and the `MadaraParser` class)
### Parser class skeleton
Parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
`MangaSourceParser` annotation that provides internal name, title and language of a manga source.
All functions in `MangaParser` class are documented. Pay attention to some peculiarities:
- Never hardcode domain. Specify default domain in `configKeyDomain` field and obtain an actual one using `getDomain()`.
- All ids must be unique and domain-independent. Use `generateUid` functions with relative url or some internal id which
is unique across the manga source.
- `sortOrders` set should not be empty. If your source is not support sorting, specify one most relevance value.
- If you cannot obtain direct links to pages images inside `getPages` method, it is ok to use an intermediate url
as `Page.url` and fetch a direct link at `getPageUrl` function.
- You can use _asserts_ to check some optional fields. For example. `Manga.author` field is not required, but if your
source provide such information, add `assert(it != null)`. This will not have any effect on production but help to
find issues during unit testing.
- If your source website (or it's api) uses pages for pagination instead of offset you should extend `PagedMangaParser`
instead of `MangaParser`.
- Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and/or
responses, including image loading.
## Development process
During the development it is recommended (but not necessary) to write it directly
in the Kotatsu android application project. You can use `core.parser.DummyParser` class as a sandbox. `Dummy` manga
source is available in debug Kotatsu build.
Once parser is ready you can relocate your code into `kotatsu-parsers` library project in a `site` package and create a
Pull Request.
### Testing
It is recommended to run unit tests before submitting a PR.
- Temporary modify the `MangaSources` annotation class: specify your parser(s) name(s) and change mode
to `EnumSource.Mode.INCLUDE`
- Run the `MangaParserTest` (`gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest"`)
- Optionally, you can run the `generateTestsReport` gradle task to get a pretty readable html report from test results.
## Help
If you need a help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp)
or [Discord server](https://discord.gg/NNJ5RgVBC5).

View File

@@ -1,49 +1,61 @@
# Kotatsu parsers # Kotatsu parsers
Library that provides manga sources. This library provides manga sources.
[![](https://jitpack.io/v/nv95/kotatsu-parsers.svg)](https://jitpack.io/#nv95/kotatsu-parsers) ![Kotlin](https://img.shields.io/github/languages/top/nv95/kotatsu-parsers) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![](https://jitpack.io/v/KotatsuApp/kotatsu-parsers.svg)](https://jitpack.io/#KotatsuApp/kotatsu-parsers) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/kotatsu-parsers) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Usage ## Usage
1. Add it in your root build.gradle at the end of repositories: 1. Add it to your root build.gradle at the end of repositories:
```groovy ```groovy
allprojects { allprojects {
repositories { repositories {
... ...
maven { url 'https://jitpack.io' } maven { url 'https://jitpack.io' }
} }
} }
``` ```
2. Add the dependency 2. Add the dependency
For Java/Kotlin project: For Java/Kotlin project:
```groovy ```groovy
dependencies { dependencies {
implementation("com.github.nv95:kotatsu-parsers:$parsers_version") implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version")
} }
``` ```
For Android project: For Android project:
```groovy ```groovy
dependencies { dependencies {
implementation("com.github.nv95:kotatsu-parsers:$parsers_version") { implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version") {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
} }
``` ```
See for versions at [JitPack](https://jitpack.io/#nv95/kotatsu-parsers) Versions are available on [JitPack](https://jitpack.io/#KotatsuApp/kotatsu-parsers)
3. Usage in code 3. Usage in code
```kotlin ```kotlin
val parser = MangaSource.MANGADEX.newParser(mangaLoaderContext) val parser = MangaSource.MANGADEX.newParser(mangaLoaderContext)
``` ```
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
See [Android](https://github.com/nv95/Kotatsu/blob/devel/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt) and [Non-Android](https://github.com/nv95/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt) implementation examples.
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated. `mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
See examples
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt)
and [Non-Android](https://github.com/KotatsuApp/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt)
implementation.
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
## Contribution
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
## DMCA disclaimer
The developers of this application have no affiliation with the content available in the app. It is collected from
sources freely available through any web browser.

View File

@@ -1,3 +1,5 @@
import tasks.ReportGenerateTask
plugins { plugins {
id 'java-library' id 'java-library'
id 'org.jetbrains.kotlin.jvm' id 'org.jetbrains.kotlin.jvm'
@@ -18,6 +20,7 @@ compileKotlin {
freeCompilerArgs += [ freeCompilerArgs += [
'-opt-in=kotlin.RequiresOptIn', '-opt-in=kotlin.RequiresOptIn',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi', '-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
] ]
} }
@@ -51,18 +54,21 @@ afterEvaluate {
} }
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okio:okio:3.1.0' implementation 'com.squareup.okio:okio:3.2.0'
implementation 'org.jsoup:jsoup:1.15.1' api 'org.jsoup:jsoup:1.15.3'
implementation 'org.json:json:20220320' implementation 'org.json:json:20220320'
implementation 'androidx.collection:collection-ktx:1.2.0' implementation 'androidx.collection:collection-ktx:1.2.0'
ksp project(':kotatsu-parsers-ksp') ksp project(':kotatsu-parsers-ksp')
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2' testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
testImplementation 'io.webfolder:quickjs:1.1.0' testImplementation 'io.webfolder:quickjs:1.1.0'
} }
//noinspection ConfigurationAvoidance
task generateTestsReport(type: ReportGenerateTask)

13
buildSrc/build.gradle Normal file
View File

@@ -0,0 +1,13 @@
plugins {
id('org.jetbrains.kotlin.jvm') version '1.6.21'
}
repositories {
mavenCentral()
}
dependencies {
implementation gradleApi()
implementation 'org.simpleframework:simple-xml:2.7.1'
implementation 'com.soywiz.korlibs.korte:korte-jvm:3.0.0-Beta5'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3'
}

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
buildSrc/gradlew vendored Executable file
View File

@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
buildSrc/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,68 @@
package tasks
import com.soywiz.korte.Template
import kotlinx.coroutines.runBlocking
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import org.simpleframework.xml.Serializer
import org.simpleframework.xml.core.Persister
import tasks.model.TestCase
import tasks.model.TestSuite
import java.io.File
import kotlin.math.roundToInt
open class ReportGenerateTask : DefaultTask() {
@TaskAction
fun execute() {
val reportsRoot = File(project.rootDir, "build/test-results/test")
val outputRoot = File(project.rootDir, "build/test-results-html")
outputRoot.deleteRecursively()
outputRoot.mkdir()
for (file in checkNotNull(reportsRoot.listFiles()) {
"No files found in $reportsRoot"
}) {
if (file.isDirectory) {
continue
}
val report = makeReport(file)
val output = File(outputRoot, file.nameWithoutExtension + ".htm")
output.writeText(report)
println("Report generated: ${output.absolutePath}")
}
}
private fun makeReport(file: File): String {
val serializer: Serializer = Persister()
val testSuite = serializer.read(TestSuite::class.java, file)
val templateText = javaClass.classLoader.getResourceAsStream("report.html")?.use {
it.bufferedReader().readText()
}
val results = LinkedHashMap<String, LinkedHashMap<String, TestCase>>()
val tests = LinkedHashSet<String>()
for (case in testSuite.testCases) {
tests.add(case.testName)
val map = results.getOrPut(case.source) { LinkedHashMap() }
val oldValue = map.put(case.testName, case)
check(oldValue == null)
}
val failPercent = (testSuite.failures.toDouble() / testSuite.tests * 100.0).roundToInt()
val errorPercent = (testSuite.errors.toDouble() / testSuite.tests * 100.0).roundToInt()
return runBlocking {
val template = Template(requireNotNull(templateText))
template(
mapOf(
"testSuite" to testSuite,
"tests" to tests,
"results" to results,
"success_percent" to 100 - (failPercent + errorPercent),
"error_percent" to errorPercent,
"fail_percent" to failPercent,
"success" to testSuite.tests - (testSuite.failures + testSuite.errors),
),
)
}
}
}

View File

@@ -0,0 +1,24 @@
package tasks.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.Root
import org.simpleframework.xml.Text
@Root(name = "failure", strict = false)
class Failure {
@JvmField
@field:Attribute(name = "message")
var message: String = ""
@JvmField
@field:Attribute(name = "type")
var type: String = ""
@JvmField
@field:Text
var text: String = ""
fun textHtml(): String {
return text.replace("\n", "<br>")
}
}

View File

@@ -0,0 +1,32 @@
package tasks.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.Element
import org.simpleframework.xml.Root
@Root(name = "testcase", strict = false)
class TestCase {
@JvmField
@field:Attribute(name = "name")
var name: String = ""
@JvmField
@field:Attribute(name = "time")
var time: Float = 0f
@JvmField
@field:Element(name = "failure", required = false)
var failure: Failure? = null
val index by lazy {
name.split('|')[0].toInt()
}
val testName by lazy {
name.split('|')[1]
}
val source by lazy {
name.split('|')[2]
}
}

View File

@@ -0,0 +1,27 @@
package tasks.model
import org.simpleframework.xml.Attribute
import org.simpleframework.xml.ElementList
import org.simpleframework.xml.Root
@Root(name = "testsuite", strict = false)
class TestSuite {
@JvmField
@field:Attribute(name = "name")
var name: String = ""
@JvmField
@field:Attribute(name = "tests")
var tests: Int = 0
@JvmField
@field:Attribute(name = "failures")
var failures: Int = 0
@JvmField
@field:Attribute(name = "errors")
var errors: Int = 0
@field:ElementList(entry = "testcase", inline = true)
lateinit var testCases: List<TestCase>
}

View File

@@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>{{ testSuite.name }}</title>
<!-- CSS only -->
<link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css"
integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" rel="stylesheet">
<!-- JavaScript Bundle with Popper -->
<script crossorigin="anonymous"
integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3"
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
</head>
<body class="py-4">
<div class="container">
<h1 class="h2">{{ testSuite.name }}</h1>
<div class="progress mt-4">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ success_percent }}%">
{{ success }} ({{ success_percent }}%)
</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ fail_percent }}%">
{{ testSuite.failures }} ({{ fail_percent }}%)
</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ error_percent }}%">
{{ testSuite.errors }} ({{ error_percent }}%)
</div>
</div>
<table class="table table-hover">
<thead class="sticky-top bg-body">
<tr>
<th scope="col">Source</th>
{% for test in tests %}
<th class="text-center" scope="col" style="min-width: 5em;">{{ test }}</th>
{% endfor %}
</tr>
</thead>
{% for name, cases in results %}
<tr>
<th scope="row">{{ name }}</th>
{% for test in tests %}
{% set case = cases[test] %}
{% if case.failure == null %}
<td class="table-success text-center">
<i data-feather="check"></i>
</td>
{% else %}
{% if case.failure.type == 'java.lang.AssertionError' %}
<td class="table-warning text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="alert-triangle"></i>
</td>
{% elseif case.failure.type == 'java.net.SocketTimeoutException' or case.failure.type ==
'java.net.UnknownHostException' %}
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="power"></i>
</td>
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.exception.CloudFlareProtectedException' %}
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="shield"></i>
</td>
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.exception.AuthRequiredException' %}
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="user-x"></i>
</td>
{% else %}
<td class="table-danger text-center" data-bs-target="#failure_{{ case.hashCode }}"
data-bs-toggle="modal" style="cursor: pointer;">
<i data-feather="x"></i>
</td>
{% endif %}
<!--suppress HtmlUnknownTag -->
<div class="modal fade" id="failure_{{ case.hashCode }}" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ case.testName }} failed</h5>
<button aria-label="Close" class="btn-close" data-bs-dismiss="modal"
type="button"></button>
</div>
<div class="modal-body font-monospace lh-sm bg-light" style="font-size: 0.7em;">
{{ case.failure.textHtml()|raw }}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal" type="button">Close</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
</div>
<script>
feather.replace()
</script>
</body>
</html>

View File

@@ -69,6 +69,7 @@ class ParserProcessor(
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@Suppress("DEPRECATION")
fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) { fun MangaSource.newParser(context: MangaLoaderContext): MangaParser = when (this) {
""".trimIndent(), """.trimIndent(),
@@ -121,6 +122,7 @@ class ParserProcessor(
logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration) logger.error("Only non-abstract can be annotated with @MangaSourceParser", classDeclaration)
} }
val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" } val annotation = classDeclaration.annotations.single { it.shortName.asString() == "MangaSourceParser" }
val deprecation = classDeclaration.annotations.singleOrNull { it.shortName.asString() == "Deprecated" }
val name = annotation.arguments.single { it.name?.asString() == "name" }.value as String val name = annotation.arguments.single { it.name?.asString() == "name" }.value as String
val title = annotation.arguments.single { it.name?.asString() == "title" }.value as String val title = annotation.arguments.single { it.name?.asString() == "title" }.value as String
val locale = annotation.arguments.single { it.name?.asString() == "locale" }.value as String val locale = annotation.arguments.single { it.name?.asString() == "locale" }.value as String
@@ -142,11 +144,15 @@ class ParserProcessor(
classDeclaration, classDeclaration,
) )
} }
val className = classDeclaration.qualifiedName?.asString() val className = classDeclaration.qualifiedName?.asString()
factoryWriter?.write("\tMangaSource.$name -> $className(context)\n") factoryWriter?.write("\tMangaSource.$name -> $className(context)\n")
val deprecationString = if (deprecation != null) {
val reason = deprecation.arguments
.find { it.name?.asString() == "message" }?.value?.toString() ?: "Unknown reason"
"@Deprecated(\"$reason\") "
} else ""
val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty() val localeComment = localeTitle?.toTitleCase(localeObj)?.let { " /* $it */" }.orEmpty()
sourcesWriter?.write("\t$name(\"$title\", $localeString$localeComment),\n") sourcesWriter?.write("\t$deprecationString$name(\"$title\", $localeString$localeComment),\n")
} }
} }
} }

View File

@@ -1,157 +1,29 @@
package org.koitharu.kotatsu.parsers package org.koitharu.kotatsu.parsers
import okhttp3.* import okhttp3.CookieJar
import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.exception.GraphQLException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import java.util.* import java.util.*
abstract class MangaLoaderContext { abstract class MangaLoaderContext {
protected abstract val httpClient: OkHttpClient abstract val httpClient: OkHttpClient
abstract val cookieJar: CookieJar abstract val cookieJar: CookieJar
/** open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
* Do a GET http request to specific url
* @param url
* @param headers an additional headers for request, may be null
*/
suspend fun httpGet(url: String, headers: Headers? = null): Response {
val request = Request.Builder()
.get()
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/** open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
* Do a HEAD http request to specific url
* @param url
* @param headers an additional headers for request, may be null
*/
suspend fun httpHead(url: String, headers: Headers? = null): Response {
val request = Request.Builder()
.head()
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/** open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
* @param headers an additional headers for request, may be null
*/
suspend fun httpPost(
url: String,
form: Map<String, String>,
headers: Headers? = null,
): Response {
val body = FormBody.Builder()
form.forEach { (k, v) ->
body.addEncoded(k, v)
}
val request = Request.Builder()
.post(body.build())
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/** /**
* Do a POST http request to specific url with `multipart/form-data` payload * Execute JavaScript code and return result
* @param url * @param script JavaScript source code
* @param payload payload as `key=value` string with `&` separator * @return execution result as string, may be null
* @param headers an additional headers for request, may be null */
*/ abstract suspend fun evaluateJs(script: String): String?
suspend fun httpPost(
url: String,
payload: String,
headers: Headers?,
): Response {
val body = FormBody.Builder()
payload.split('&').forEach {
val pos = it.indexOf('=')
if (pos != -1) {
val k = it.substring(0, pos)
val v = it.substring(pos + 1)
body.addEncoded(k, v)
}
}
val request = Request.Builder()
.post(body.build())
.url(url)
if (headers != null) {
request.headers(headers)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
/** abstract fun getConfig(source: MangaSource): MangaSourceConfig
* Do a GraphQL request to specific url }
* @param endpoint an url
* @param query GraphQL request payload
*/
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
val body = JSONObject()
body.put("operationName", null as Any?)
body.put("variables", JSONObject())
body.put("query", "{$query}")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(endpoint)
val json = httpClient.newCall(request.build()).await().ensureSuccess().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
/**
* Execute JavaScript code and return result
* @param script JavaScript source code
* @return execution result as string, may be null
*/
abstract suspend fun evaluateJs(script: String): String?
abstract fun getConfig(source: MangaSource): MangaSourceConfig
private fun Response.ensureSuccess() = apply {
val exception: Exception? = when (code) { // Catch some error codes, not all
in 500..599 -> HttpStatusException(message, code, request.url.toString())
else -> null
}
if (exception != null) {
runCatching {
close()
}.onFailure {
exception.addSuppressed(it)
}
throw exception
}
}
}

View File

@@ -1,15 +1,20 @@
package org.koitharu.kotatsu.parsers package org.koitharu.kotatsu.parsers
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import okhttp3.Headers
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.network.WebClient
import org.koitharu.kotatsu.parsers.util.FaviconParser
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import java.util.* import java.util.*
abstract class MangaParser @InternalParsersApi constructor(val source: MangaSource) { abstract class MangaParser @InternalParsersApi constructor(
@property:InternalParsersApi val context: MangaLoaderContext,
protected abstract val context: MangaLoaderContext val source: MangaSource,
) {
/** /**
* Supported [SortOrder] variants. Must not be empty. * Supported [SortOrder] variants. Must not be empty.
@@ -20,12 +25,18 @@ abstract class MangaParser @InternalParsersApi constructor(val source: MangaSour
val config by lazy { context.getConfig(source) } val config by lazy { context.getConfig(source) }
open val sourceLocale: Locale
get() = source.locale?.let { Locale(it) } ?: Locale.ROOT
/** /**
* Provide default domain and available alternatives, if any. * Provide default domain and available alternatives, if any.
* *
* Never hardcode domain in requests, use [getDomain] instead. * Never hardcode domain in requests, use [getDomain] instead.
*/ */
protected abstract val configKeyDomain: ConfigKey.Domain @InternalParsersApi
abstract val configKeyDomain: ConfigKey.Domain
open val headers: Headers? = null
/** /**
* Used as fallback if value of `sortOrder` passed to [getList] is null * Used as fallback if value of `sortOrder` passed to [getList] is null
@@ -36,6 +47,8 @@ abstract class MangaParser @InternalParsersApi constructor(val source: MangaSour
return SortOrder.values().first { it in supported } return SortOrder.values().first { it in supported }
} }
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
/** /**
* Parse list of manga by specified criteria * Parse list of manga by specified criteria
* *
@@ -60,7 +73,7 @@ abstract class MangaParser @InternalParsersApi constructor(val source: MangaSour
* @param offset starting from 0 and used for pagination. * @param offset starting from 0 and used for pagination.
* @param query search query * @param query search query
*/ */
suspend fun getList(offset: Int, query: String): List<Manga> { open suspend fun getList(offset: Int, query: String): List<Manga> {
return getList(offset, query, null, defaultSortOrder) return getList(offset, query, null, defaultSortOrder)
} }
@@ -72,7 +85,7 @@ abstract class MangaParser @InternalParsersApi constructor(val source: MangaSour
* @param tags genres for filtering, values from [getTags] and [Manga.tags]. May be null or empty * @param tags genres for filtering, values from [getTags] and [Manga.tags]. May be null or empty
* @param sortOrder one of [sortOrders] or null for default value * @param sortOrder one of [sortOrders] or null for default value
*/ */
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> { open suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return getList(offset, null, tags, sortOrder ?: defaultSortOrder) return getList(offset, null, tags, sortOrder ?: defaultSortOrder)
} }
@@ -92,7 +105,7 @@ abstract class MangaParser @InternalParsersApi constructor(val source: MangaSour
/** /**
* Fetch direct link to the page image. * Fetch direct link to the page image.
*/ */
open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(getDomain()) open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
/** /**
* Fetch available tags (genres) for source * Fetch available tags (genres) for source
@@ -100,64 +113,14 @@ abstract class MangaParser @InternalParsersApi constructor(val source: MangaSour
abstract suspend fun getTags(): Set<MangaTag> abstract suspend fun getTags(): Set<MangaTag>
/** /**
* Returns direct link to the website favicon * Parse favicons from the main page of the source`s website
*/ */
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico" open suspend fun getFavicons(): Favicons {
return FaviconParser(webClient, domain).parseFavicons()
}
@CallSuper @CallSuper
open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) { open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
keys.add(configKeyDomain) keys.add(configKeyDomain)
} }
}
/* Utils */
fun getDomain(): String {
return config[configKeyDomain]
}
fun getDomain(subdomain: String): String {
val domain = getDomain()
return subdomain + "." + domain.removePrefix("www.")
}
/**
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
* @param url must be relative url, without a domain
* @see [Manga.id]
* @see [MangaChapter.id]
* @see [MangaPage.id]
*/
@InternalParsersApi
protected fun generateUid(url: String): Long {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
return h
}
/**
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
* @param id an internal identifier
* @see [Manga.id]
* @see [MangaChapter.id]
* @see [MangaPage.id]
*/
@InternalParsersApi
protected fun generateUid(id: Long): Long {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
h = 31 * h + id
return h
}
@InternalParsersApi
protected fun parseFailed(message: String? = null): Nothing {
throw ParseException(message, null)
}
}

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.parsers
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.Paginator
@InternalParsersApi
abstract class PagedMangaParser(
context: MangaLoaderContext,
source: MangaSource,
pageSize: Int,
searchPageSize: Int = pageSize,
) : MangaParser(context, source) {
protected val paginator = Paginator(pageSize)
protected val searchPaginator = Paginator(searchPageSize)
override suspend fun getList(offset: Int, query: String): List<Manga> {
return getList(searchPaginator, offset, query, null, defaultSortOrder)
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return getList(paginator, offset, null, tags, sortOrder ?: defaultSortOrder)
}
@InternalParsersApi
@Deprecated("You should use getListPage for PagedMangaParser", level = DeprecationLevel.HIDDEN)
final override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = throw UnsupportedOperationException("You should use getListPage for PagedMangaParser")
abstract suspend fun getListPage(page: Int, query: String?, tags: Set<MangaTag>?, sortOrder: SortOrder): List<Manga>
private suspend fun getList(
paginator: Paginator,
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val page = paginator.getPage(offset)
val list = getListPage(page, query, tags, sortOrder)
paginator.onListReceived(offset, page, list.size)
return list
}
}

View File

@@ -1,13 +1,21 @@
package org.koitharu.kotatsu.parsers.config package org.koitharu.kotatsu.parsers.config
sealed class ConfigKey<T>( sealed class ConfigKey<T>(
val key: String, @JvmField val key: String,
) { ) {
abstract val defaultValue: T abstract val defaultValue: T
class Domain( class Domain(
override val defaultValue: String, override val defaultValue: String,
val presetValues: Array<String>?, @JvmField val presetValues: Array<String>?,
) : ConfigKey<String>("domain") ) : ConfigKey<String>("domain")
}
class ShowSuspiciousContent(
override val defaultValue: Boolean,
) : ConfigKey<Boolean>("show_suspicious")
class UserAgent(
override val defaultValue: String,
) : ConfigKey<String>("user_agent")
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.parsers.exception package org.koitharu.kotatsu.parsers.exception
import okhttp3.Headers
import okio.IOException import okio.IOException
class CloudFlareProtectedException( class CloudFlareProtectedException(
val url: String, val url: String,
val headers: Headers,
) : IOException("Protected by CloudFlare: $url") ) : IOException("Protected by CloudFlare: $url")

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.parsers.exception
class ContentUnavailableException(message: String) : RuntimeException(message)

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.parsers.exception
import org.jsoup.HttpStatusException
import java.net.HttpURLConnection
class NotFoundException(
message: String,
url: String,
) : HttpStatusException(message, HttpURLConnection.HTTP_NOT_FOUND, url)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.parsers.exception
import org.koitharu.kotatsu.parsers.InternalParsersApi import org.koitharu.kotatsu.parsers.InternalParsersApi
class ParseException @InternalParsersApi @JvmOverloads constructor( class ParseException @InternalParsersApi @JvmOverloads constructor(
message: String?, val shortMessage: String?,
val url: String,
cause: Throwable? = null, cause: Throwable? = null,
) : RuntimeException(message, cause) ) : RuntimeException("$shortMessage at $url", cause)

View File

@@ -0,0 +1,52 @@
package org.koitharu.kotatsu.parsers.model
import okhttp3.HttpUrl.Companion.toHttpUrl
class Favicon internal constructor(
@JvmField val url: String,
@JvmField val size: Int,
@JvmField internal val rel: String?,
) : Comparable<Favicon> {
@JvmField
val type: String = url.toHttpUrl().pathSegments.last()
.substringAfterLast('.', "").lowercase()
override fun compareTo(other: Favicon): Int {
val res = size.compareTo(other.size)
if (res != 0) {
return res
}
return relWeightOf(rel).compareTo(relWeightOf(other.rel))
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Favicon
if (url != other.url) return false
if (size != other.size) return false
if (rel != other.rel) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + size
result = 31 * result + rel.hashCode()
return result
}
override fun toString(): String {
return "Favicon(size=$size, type='$type', rel='$rel', url='$url')"
}
private fun relWeightOf(rel: String?) = when (rel) {
"apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality
"mask-icon" -> -1
else -> 0
}
}

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu.parsers.model
class Favicons internal constructor(
favicons: Collection<Favicon>,
@JvmField val referer: String,
) : Collection<Favicon> {
private val icons = favicons.sortedDescending()
override val size: Int
get() = icons.size
override fun contains(element: Favicon): Boolean = icons.contains(element)
override fun containsAll(elements: Collection<Favicon>): Boolean = icons.containsAll(elements)
override fun isEmpty(): Boolean = icons.isEmpty()
override fun iterator(): Iterator<Favicon> = icons.iterator()
/**
* Finds a favicon whose size in pixels is greater than or equal to the specified size.
* If such icon is not available returns the largest icon
* @param size in pixels
* @param types supported file types, e.g. png, svg, ico. May be null but not empty
*/
@JvmOverloads
fun find(size: Int, types: Set<String>? = null): Favicon? {
if (icons.isEmpty()) {
return null
}
var result: Favicon? = null
for (icon in icons) {
if (types != null && icon.type !in types) {
continue
}
if (result == null || icon.size >= size) {
result = icon
} else {
break
}
}
return result
}
}

View File

@@ -6,67 +6,67 @@ class Manga(
/** /**
* Unique identifier for manga * Unique identifier for manga
*/ */
val id: Long, @JvmField val id: Long,
/** /**
* Manga title, human-readable * Manga title, human-readable
*/ */
val title: String, @JvmField val title: String,
/** /**
* Alternative title (for example on other language), may be null * Alternative title (for example on other language), may be null
*/ */
val altTitle: String?, @JvmField val altTitle: String?,
/** /**
* Relative url to manga (**without** a domain) or any other uri. * Relative url to manga (**without** a domain) or any other uri.
* Used principally in parsers * Used principally in parsers
*/ */
val url: String, @JvmField val url: String,
/** /**
* Absolute url to manga, must be ready to open in browser * Absolute url to manga, must be ready to open in browser
*/ */
val publicUrl: String, @JvmField val publicUrl: String,
/** /**
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown * Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
* @see hasRating * @see hasRating
*/ */
val rating: Float, @JvmField val rating: Float,
/** /**
* Indicates that manga may contain sensitive information (18+, NSFW) * Indicates that manga may contain sensitive information (18+, NSFW)
*/ */
val isNsfw: Boolean, @JvmField val isNsfw: Boolean,
/** /**
* Absolute link to the cover * Absolute link to the cover
* @see largeCoverUrl * @see largeCoverUrl
*/ */
val coverUrl: String, @JvmField val coverUrl: String,
/** /**
* Tags (genres) of the manga * Tags (genres) of the manga
*/ */
val tags: Set<MangaTag>, @JvmField val tags: Set<MangaTag>,
/** /**
* Manga status (ongoing, finished) or null if unknown * Manga status (ongoing, finished) or null if unknown
*/ */
val state: MangaState?, @JvmField val state: MangaState?,
/** /**
* Author of the manga, may be null * Author of the manga, may be null
*/ */
val author: String?, @JvmField val author: String?,
/** /**
* Large cover url (absolute), null if is no large cover * Large cover url (absolute), null if is no large cover
* @see coverUrl * @see coverUrl
*/ */
val largeCoverUrl: String? = null, @JvmField val largeCoverUrl: String? = null,
/** /**
* Manga description, may be html or null * Manga description, may be html or null
*/ */
val description: String? = null, @JvmField val description: String? = null,
/** /**
* List of chapters * List of chapters
*/ */
val chapters: List<MangaChapter>? = null, @JvmField val chapters: List<MangaChapter>? = null,
/** /**
* Manga source * Manga source
*/ */
val source: MangaSource, @JvmField val source: MangaSource,
) { ) {
/** /**
@@ -74,7 +74,7 @@ class Manga(
* @see rating * @see rating
*/ */
val hasRating: Boolean val hasRating: Boolean
get() = rating in 0f..1f get() = rating > 0f && rating <= 1f
fun getChapters(branch: String?): List<MangaChapter>? { fun getChapters(branch: String?): List<MangaChapter>? {
return chapters?.filter { x -> x.branch == branch } return chapters?.filter { x -> x.branch == branch }
@@ -155,4 +155,8 @@ class Manga(
result = 31 * result + source.hashCode() result = 31 * result + source.hashCode()
return result return result
} }
}
override fun toString(): String {
return "Manga($id - \"$title\" [$url] - $source)"
}
}

View File

@@ -4,34 +4,34 @@ class MangaChapter(
/** /**
* An unique id of chapter * An unique id of chapter
*/ */
val id: Long, @JvmField val id: Long,
/** /**
* User-readable name of chapter * User-readable name of chapter
*/ */
val name: String, @JvmField val name: String,
/** /**
* Chapter number starting from 1 * Chapter number starting from 1
*/ */
val number: Int, @JvmField val number: Int,
/** /**
* Relative url to chapter (**without** a domain) or any other uri. * Relative url to chapter (**without** a domain) or any other uri.
* Used principally in parsers * Used principally in parsers
*/ */
val url: String, @JvmField val url: String,
/** /**
* User-readable name of scanlator (releaser) or null if unknown * User-readable name of scanlator (releaser) or null if unknown
*/ */
val scanlator: String?, @JvmField val scanlator: String?,
/** /**
* Chapter upload date in milliseconds * Chapter upload date in milliseconds
*/ */
val uploadDate: Long, @JvmField val uploadDate: Long,
/** /**
* User-readable name of branch. * User-readable name of branch.
* A branch is a group of chapters that overlap (e.g. different languages) * A branch is a group of chapters that overlap (e.g. different languages)
*/ */
val branch: String?, @JvmField val branch: String?,
val source: MangaSource, @JvmField val source: MangaSource,
) : Comparable<MangaChapter> { ) : Comparable<MangaChapter> {
override fun compareTo(other: MangaChapter): Int { override fun compareTo(other: MangaChapter): Int {
@@ -67,4 +67,8 @@ class MangaChapter(
result = 31 * result + source.hashCode() result = 31 * result + source.hashCode()
return result return result
} }
}
override fun toString(): String {
return "MangaChapter($id - #$number [$url] - $source)"
}
}

View File

@@ -6,24 +6,19 @@ class MangaPage(
/** /**
* Unique identifier for manga * Unique identifier for manga
*/ */
val id: Long, @JvmField val id: Long,
/** /**
* Relative url to page (**without** a domain) or any other uri. * Relative url to page (**without** a domain) or any other uri.
* Used principally in parsers. * Used principally in parsers.
* May contain link to image or html page. * May contain link to image or html page.
* @see MangaParser.getPageUrl * @see MangaParser.getPageUrl
*/ */
val url: String, @JvmField val url: String,
/**
* Absolute link to the chapter or website home page.
* Used in Referer header
*/
val referer: String,
/** /**
* Absolute url of the small page image if exists, null otherwise * Absolute url of the small page image if exists, null otherwise
*/ */
val preview: String?, @JvmField val preview: String?,
val source: MangaSource, @JvmField val source: MangaSource,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -34,19 +29,19 @@ class MangaPage(
if (id != other.id) return false if (id != other.id) return false
if (url != other.url) return false if (url != other.url) return false
if (referer != other.referer) return false
if (preview != other.preview) return false if (preview != other.preview) return false
if (source != other.source) return false return source == other.source
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = id.hashCode() var result = id.hashCode()
result = 31 * result + url.hashCode() result = 31 * result + url.hashCode()
result = 31 * result + referer.hashCode()
result = 31 * result + (preview?.hashCode() ?: 0) result = 31 * result + (preview?.hashCode() ?: 0)
result = 31 * result + source.hashCode() result = 31 * result + source.hashCode()
return result return result
} }
}
override fun toString(): String {
return "MangaPage($id [$url] - $source)"
}
}

View File

@@ -6,13 +6,13 @@ class MangaTag(
/** /**
* User-readable tag title, should be in Title case * User-readable tag title, should be in Title case
*/ */
val title: String, @JvmField val title: String,
/** /**
* Identifier of a tag, must be unique among the source. * Identifier of a tag, must be unique among the source.
* @see MangaParser.getList * @see MangaParser.getList
*/ */
val key: String, @JvmField val key: String,
val source: MangaSource, @JvmField val source: MangaSource,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -34,4 +34,8 @@ class MangaTag(
result = 31 * result + source.hashCode() result = 31 * result + source.hashCode()
return result return result
} }
}
override fun toString(): String {
return "MangaTag($key \"$title\" - $source)"
}
}

View File

@@ -0,0 +1,118 @@
package org.koitharu.kotatsu.parsers.network
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.GraphQLException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import java.net.HttpURLConnection
class OkHttpWebClient(
private val httpClient: OkHttpClient,
private val mangaSource: MangaSource,
) : WebClient {
override suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response {
val request = Request.Builder()
.get()
.url(url)
.addTags()
if (extraHeaders != null) {
request.headers(extraHeaders)
}
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpHead(url: HttpUrl): Response {
val request = Request.Builder()
.head()
.url(url)
.addTags()
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response {
val body = FormBody.Builder()
form.forEach { (k, v) ->
body.addEncoded(k, v)
}
val request = Request.Builder()
.post(body.build())
.url(url)
.addTags()
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpPost(url: HttpUrl, payload: String): Response {
val body = FormBody.Builder()
payload.split('&').forEach {
val pos = it.indexOf('=')
if (pos != -1) {
val k = it.substring(0, pos)
val v = it.substring(pos + 1)
body.addEncoded(k, v)
}
}
val request = Request.Builder()
.post(body.build())
.url(url)
.addTags()
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun httpPost(url: HttpUrl, body: JSONObject): Response {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(url)
.addTags()
return httpClient.newCall(request.build()).await().ensureSuccess()
}
override suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
val body = JSONObject()
body.put("operationName", null as Any?)
body.put("variables", JSONObject())
body.put("query", "{$query}")
val json = httpPost(endpoint, body).parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
private fun Request.Builder.addTags(): Request.Builder {
tag(MangaSource::class.java, mangaSource)
return this
}
private fun Response.ensureSuccess(): Response {
val exception: Exception? = when (code) { // Catch some error codes, not all
HttpURLConnection.HTTP_NOT_FOUND -> NotFoundException(message, request.url.toString())
HttpURLConnection.HTTP_UNAUTHORIZED -> request.tag(MangaSource::class.java)?.let {
AuthRequiredException(it)
} ?: HttpStatusException(message, code, request.url.toString())
in 400..599 -> HttpStatusException(message, code, request.url.toString())
else -> null
}
if (exception != null) {
runCatching {
close()
}.onFailure {
exception.addSuppressed(it)
}
throw exception
}
return this
}
}

View File

@@ -0,0 +1,87 @@
package org.koitharu.kotatsu.parsers.network
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.json.JSONObject
interface WebClient {
/**
* Do a GET http request to specific url
* @param url
*/
suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl())
suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders)
/**
* Do a GET http request to specific url
* @param url
*/
suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null)
suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response
/**
* Do a HEAD http request to specific url
* @param url
*/
suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl())
/**
* Do a HEAD http request to specific url
* @param url
*/
suspend fun httpHead(url: HttpUrl): Response
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
*/
suspend fun httpPost(url: String, form: Map<String, String>): Response = httpPost(url.toHttpUrl(), form)
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param form payload as key=>value map
*/
suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param payload payload as `key=value` string with `&` separator
*/
suspend fun httpPost(url: String, payload: String): Response = httpPost(url.toHttpUrl(), payload)
/**
* Do a POST http request to specific url with `multipart/form-data` payload
* @param url
* @param payload payload as `key=value` string with `&` separator
*/
suspend fun httpPost(url: HttpUrl, payload: String): Response
/**
* Do a POST http request to specific url with json payload
* @param url
* @param body
*/
suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body)
/**
* Do a POST http request to specific url with json payload
* @param url
* @param body
*/
suspend fun httpPost(url: HttpUrl, body: JSONObject): Response
/**
* Do a GraphQL request to specific url
* @param endpoint an url
* @param query GraphQL request payload
*/
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject
}

View File

@@ -8,6 +8,9 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.getDomain
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
import org.koitharu.kotatsu.parsers.util.json.stringIterator import org.koitharu.kotatsu.parsers.util.json.stringIterator
@@ -15,7 +18,7 @@ import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import java.util.* import java.util.*
@MangaSourceParser("ANIBEL", "Anibel", "be") @MangaSourceParser("ANIBEL", "Anibel", "be")
internal class AnibelParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.ANIBEL) { internal class AnibelParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.ANIBEL) {
override val configKeyDomain = ConfigKey.Domain("anibel.net", null) override val configKeyDomain = ConfigKey.Domain("anibel.net", null)
@@ -23,10 +26,6 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/favicons/favicon.png"
}
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
@@ -78,7 +77,7 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
isNsfw = false, isNsfw = false,
rating = jo.getDouble("rating").toFloat() / 10f, rating = jo.getDouble("rating").toFloat() / 10f,
url = href, url = href,
publicUrl = "https://${getDomain()}/$href", publicUrl = "https://${domain}/$href",
tags = jo.getJSONArray("genres").mapToTags(), tags = jo.getJSONArray("genres").mapToTags(),
state = when (jo.getString("status")) { state = when (jo.getString("status")) {
"ongoing" -> MangaState.ONGOING "ongoing" -> MangaState.ONGOING
@@ -165,12 +164,11 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
""".trimIndent(), """.trimIndent(),
).getJSONObject("chapter") ).getJSONObject("chapter")
val pages = chapterJson.getJSONArray("images") val pages = chapterJson.getJSONArray("images")
val chapterUrl = "https://${getDomain()}/${chapter.url}" val chapterUrl = "https://${domain}/${chapter.url}"
return pages.mapJSONIndexed { i, jo -> return pages.mapJSONIndexed { i, jo ->
MangaPage( MangaPage(
id = generateUid("${chapter.url}/$i"), id = generateUid("${chapter.url}/$i"),
url = jo.getString("large"), url = jo.getString("large"),
referer = chapterUrl,
preview = jo.getString("thumbnail"), preview = jo.getString("thumbnail"),
source = source, source = source,
) )
@@ -219,7 +217,7 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
isNsfw = false, isNsfw = false,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
url = href, url = href,
publicUrl = "https://${getDomain()}/$href", publicUrl = "https://${domain}/$href",
tags = emptySet(), tags = emptySet(),
state = null, state = null,
source = source, source = source,
@@ -228,7 +226,7 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
} }
private suspend fun apiCall(request: String): JSONObject { private suspend fun apiCall(request: String): JSONObject {
return context.graphQLQuery("https://api.${getDomain()}/graphql", request) return webClient.graphQLQuery("https://api.${domain}/graphql", request)
.getJSONObject("data") .getJSONObject("data")
} }
@@ -242,6 +240,7 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
c == '-' -> { c == '-' -> {
builder.setCharAt(i, ' ') builder.setCharAt(i, ' ')
} }
capitalize -> { capitalize -> {
builder.setCharAt(i, c.uppercaseChar()) builder.setCharAt(i, c.uppercaseChar())
capitalize = false capitalize = false
@@ -263,4 +262,4 @@ internal class AnibelParser(override val context: MangaLoaderContext) : MangaPar
} }
return result return result
} }
} }

View File

@@ -5,9 +5,10 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -17,11 +18,13 @@ import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
private const val PAGE_SIZE = 60
private const val PAGE_SIZE_SEARCH = 20
@MangaSourceParser("BATOTO", "Bato.To") @MangaSourceParser("BATOTO", "Bato.To")
internal class BatoToParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.BATOTO) { internal class BatoToParser(context: MangaLoaderContext) : PagedMangaParser(
context = context,
source = MangaSource.BATOTO,
pageSize = 60,
searchPageSize = 20,
) {
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
@@ -32,28 +35,36 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
override val configKeyDomain = ConfigKey.Domain( override val configKeyDomain = ConfigKey.Domain(
"bato.to", "bato.to",
arrayOf("bato.to", "mto.to", "mangatoto.com", "battwo.com", "batotwo.com", "comiko.net", "batotoo.com"), arrayOf(
"bato.to",
"mto.to",
"hto.to",
"mangatoto.com",
"battwo.com",
"batotwo.com",
"comiko.net",
"batotoo.com",
),
) )
override suspend fun getList( override suspend fun getListPage(
offset: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return search(offset, query) return search(page, query)
} }
val page = (offset / PAGE_SIZE) + 1
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT") @Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
val url = buildString { val url = buildString {
append("https://") append("https://")
append(getDomain()) append(domain)
append("/browse?sort=") append("/browse?sort=")
when (sortOrder) { when (sortOrder) {
SortOrder.UPDATED, SortOrder.UPDATED,
-> append("update.za") -> append("update.za")
SortOrder.POPULARITY -> append("views_a.za") SortOrder.POPULARITY -> append("views_a.za")
SortOrder.NEWEST -> append("create.za") SortOrder.NEWEST -> append("create.za")
SortOrder.ALPHABETICAL -> append("title.az") SortOrder.ALPHABETICAL -> append("title.az")
@@ -69,9 +80,9 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val root = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml() val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
.getElementById("mainer") ?: parseFailed("Cannot find root") .requireElementById("mainer")
val details = root.selectFirst(".detail-set") ?: parseFailed("Cannot find detail-set") val details = root.selectFirstOrThrow(".detail-set")
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate { val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
it.child(0).text().trim() to it.child(1) it.child(0).text().trim() to it.child(1)
}.orEmpty() }.orEmpty()
@@ -92,19 +103,18 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
chapters = root.selectFirst(".episode-list") chapters = root.selectFirst(".episode-list")
?.selectFirst(".main") ?.selectFirst(".main")
?.children() ?.children()
?.reversed() ?.mapChapters(reversed = true) { i, div ->
?.mapIndexedNotNull { i, div ->
div.parseChapter(i) div.parseChapter(i)
}.orEmpty(), }.orEmpty(),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val scripts = context.httpGet(fullUrl).parseHtml().select("script") val scripts = webClient.httpGet(fullUrl).parseHtml().select("script")
for (script in scripts) { for (script in scripts) {
val scriptSrc = script.html() val scriptSrc = script.html()
val p = scriptSrc.indexOf("const images =") val p = scriptSrc.indexOf("const imgHttpLis =")
if (p == -1) continue if (p == -1) continue
val start = scriptSrc.indexOf('[', p) val start = scriptSrc.indexOf('[', p)
val end = scriptSrc.indexOf(';', start) val end = scriptSrc.indexOf(';', start)
@@ -112,33 +122,32 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
continue continue
} }
val images = JSONArray(scriptSrc.substring(start, end)) val images = JSONArray(scriptSrc.substring(start, end))
val batoJs = scriptSrc.substringBetweenFirst("batojs =", ";")?.trim(' ', '"', '\n') val batoPass = scriptSrc.substringBetweenFirst("batoPass =", ";")?.trim(' ', '"', '\n')
?: parseFailed("Cannot find batojs") ?: script.parseFailed("Cannot find batoPass")
val server = scriptSrc.substringBetweenFirst("server =", ";")?.trim(' ', '"', '\n') val batoWord = scriptSrc.substringBetweenFirst("batoWord =", ";")?.trim(' ', '"', '\n')
?: parseFailed("Cannot find server") ?: script.parseFailed("Cannot find batoWord")
val password = context.evaluateJs(batoJs)?.removeSurrounding('"') val password = context.evaluateJs(batoPass)?.removeSurrounding('"')
?: parseFailed("Cannot evaluate batojs") ?: script.parseFailed("Cannot evaluate batoPass")
val serverDecrypted = decryptAES(server, password).removeSurrounding('"') val args = JSONArray(decryptAES(batoWord, password))
val result = ArrayList<MangaPage>(images.length()) val result = ArrayList<MangaPage>(images.length())
repeat(images.length()) { i -> repeat(images.length()) { i ->
val url = images.getString(i) val url = images.getString(i)
result += MangaPage( result += MangaPage(
id = generateUid(url), id = generateUid(url),
url = if (url.startsWith("http")) url else "$serverDecrypted$url", url = url + "?" + args.getString(i),
referer = fullUrl,
preview = null, preview = null,
source = source, source = source,
) )
} }
return result return result
} }
parseFailed("Cannot find images list") throw ParseException("Cannot find images list", fullUrl)
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val scripts = context.httpGet( val scripts = webClient.httpGet(
"https://${getDomain()}/browse", "https://${domain}/browse",
).parseHtml().select("script") ).parseHtml().selectOrThrow("script")
for (script in scripts) { for (script in scripts) {
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
val jo = JSONObject(genres) val jo = JSONObject(genres)
@@ -153,16 +162,13 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
} }
return result return result
} }
parseFailed("Cannot find gernes list") throw ParseException("Cannot find gernes list", scripts[0].baseUri())
} }
override fun getFaviconUrl(): String = "https://styles.amarkcdn.com/img/batoto/favicon.ico?v0" private suspend fun search(page: Int, query: String): List<Manga> {
private suspend fun search(offset: Int, query: String): List<Manga> {
val page = (offset / PAGE_SIZE_SEARCH) + 1
val url = buildString { val url = buildString {
append("https://") append("https://")
append(getDomain()) append(domain)
append("/search?word=") append("/search?word=")
append(query.replace(' ', '+')) append(query.replace(' ', '+'))
append("&page=") append("&page=")
@@ -174,10 +180,10 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
private fun getActivePage(body: Element): Int = body.select("nav ul.pagination > li.page-item.active") private fun getActivePage(body: Element): Int = body.select("nav ul.pagination > li.page-item.active")
.lastOrNull() .lastOrNull()
?.text() ?.text()
?.toIntOrNull() ?: parseFailed("Cannot determine current page") ?.toIntOrNull() ?: body.parseFailed("Cannot determine current page")
private suspend fun parseList(url: String, page: Int): List<Manga> { private suspend fun parseList(url: String, page: Int): List<Manga> {
val body = context.httpGet(url).parseHtml().body() val body = webClient.httpGet(url).parseHtml().body()
if (body.selectFirst(".browse-no-matches") != null) { if (body.selectFirst(".browse-no-matches") != null) {
return emptyList() return emptyList()
} }
@@ -185,11 +191,11 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
if (activePage != page) { if (activePage != page) {
return emptyList() return emptyList()
} }
val root = body.getElementById("series-list") ?: parseFailed("Cannot find root") val root = body.requireElementById("series-list")
return root.children().map { div -> return root.children().map { div ->
val a = div.selectFirst("a") ?: parseFailed() val a = div.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val title = div.selectFirst(".item-title")?.text() ?: parseFailed("Title not found") val title = div.selectFirstOrThrow(".item-title").text()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = title, title = title,
@@ -307,4 +313,4 @@ internal class BatoToParser(override val context: MangaLoaderContext) : MangaPar
} else byteArrayOf(), } else byteArrayOf(),
) )
} }
} }

View File

@@ -0,0 +1,240 @@
package org.koitharu.kotatsu.parsers.site
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import okhttp3.Headers
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import java.util.*
@MangaSourceParser("BENTOMANGA", "Bentomanga", "fr")
internal class BentomangaParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.BENTOMANGA, 10) {
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.RATING,
SortOrder.NEWEST,
SortOrder.ALPHABETICAL,
)
override val configKeyDomain = ConfigKey.Domain("www.bentomanga.com", null)
override val headers: Headers = Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0")
.build()
init {
paginator.firstPage = 0
searchPaginator.firstPage = 0
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val url = urlBuilder()
.addPathSegment("manga_list")
.addQueryParameter("limit", page.toString())
.addQueryParameter(
"order_by",
when (sortOrder) {
SortOrder.UPDATED -> "update"
SortOrder.POPULARITY -> "views"
SortOrder.RATING -> "top"
SortOrder.NEWEST -> "create"
SortOrder.ALPHABETICAL -> "name"
},
)
if (!tags.isNullOrEmpty()) {
url.addQueryParameter("withCategories", tags.joinToString(",") { it.key })
}
if (!query.isNullOrEmpty()) {
url.addQueryParameter("search", query)
}
val root = webClient.httpGet(url.build()).parseHtml().requireElementById("mangas_content")
return root.select(".manga[data-manga]").map { div ->
val header = div.selectFirstOrThrow(".manga_header")
val href = header.selectFirstOrThrow("a").attrAsRelativeUrl("href")
Manga(
id = generateUid(href),
title = div.selectFirstOrThrow("h1").text(),
altTitle = null,
url = href,
publicUrl = href.toAbsoluteUrl(domain),
rating = div.getElementsByAttributeValue("data-icon", "avg_rate")
.firstOrNull()
?.textOrNull()
?.toFloatOrNull()
?.div(10f)
?: RATING_UNKNOWN,
isNsfw = div.selectFirst(".badge-adult_content") != null,
coverUrl = div.selectFirstOrThrow("img").src(),
tags = div.selectFirst(".component-manga-categories")
.assertNotNull("tags")
?.select("a")
?.mapToSet { a ->
MangaTag(
title = a.text().toTitleCase(sourceLocale),
key = a.attr("href").substringAfterLast('='),
source = source,
)
}.orEmpty(),
state = null,
author = null,
description = div.selectFirst(".manga_synopsis")?.html().assertNotNull("description"),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val mangaUrl = manga.url.toAbsoluteUrl(domain)
val root = webClient.httpGet(mangaUrl).parseHtml()
.requireElementById("container_manga_show")
return manga.copy(
altTitle = root.selectFirst(".component-manga-title_alt")?.textOrNull().assertNotNull("altTitle"),
description = root.selectFirst(".datas_synopsis")?.html().assertNotNull("description")
?: manga.description,
state = when (root.selectFirst(".datas_more-status-data")?.textOrNull().assertNotNull("status")) {
"En cours" -> MangaState.ONGOING
else -> null
},
author = root.selectFirst(".datas_more-authors-people")?.textOrNull().assertNotNull("author"),
chapters = run {
val input = root.selectFirst("input[name=\"limit\"]") ?: return@run parseChapters(root)
val max = input.attr("max").toInt()
if (max <= 1) {
parseChapters(root)
} else {
coroutineScope {
val result = ArrayList<MangaChapter>(parseChapters(root))
result.ensureCapacity(result.size * max)
(2..max).map { i ->
async {
loadChapters(mangaUrl, i)
}
}.awaitAll()
.flattenTo(result)
result
}
}
}.reversed(),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(chapterUrl).parseHtml()
val chapterId = doc.head().getElementsByAttribute("data-chapter-id").first()!!.attr("data-chapter-id")
val json = webClient.httpGet(
"https://$domain/api/?id=$chapterId&type=chapter",
Headers.headersOf(
"Referer", chapterUrl,
"x-requested-with", "XMLHttpRequest",
),
).parseJson()
if (json.getIntOrDefault("type", 1) == 2) {
throw ParseException("Light Novels are not supported", chapterUrl)
}
val baseUrl = json.getString("baseImagesUrl")
val pages = json.getJSONArray("page_array")
return (0 until pages.length()).map { i ->
val url = concatUrl(baseUrl, pages.getString(i))
MangaPage(
id = generateUid(url),
url = url.toAbsoluteUrl(domain),
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val root = webClient.httpGet(urlBuilder().addPathSegment("manga_list").build())
.parseHtml()
.requireElementById("search_options-form")
return root.getElementsByAttributeValue("name", "categories[]")
.mapToSet { input ->
val div = input.parents().first()!!
MangaTag(
title = div.text().toTitleCase(sourceLocale),
key = input.attr("value"),
source = source,
)
}
}
private suspend fun loadChapters(baseUrl: String, page: Int): List<MangaChapter> {
return parseChapters(webClient.httpGet("$baseUrl?limit=$page").parseHtml().body())
}
private fun parseChapters(root: Element): List<MangaChapter> {
return root.requireElementById("chapters_content")
.select(".component-chapter").map { div ->
val a = div.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href")
val title = div.selectFirstOrThrow(".chapter_volume").text()
val name = div.selectFirst(".chapter_title")?.textOrNull()
MangaChapter(
id = generateUid(href),
name = if (name != null && name != title) "$title: $name" else title,
number = href.substringAfterLast('/').toIntOrNull() ?: 0,
url = href,
scanlator = div.selectFirst(".team_link-name")?.textOrNull(),
uploadDate = div.selectFirst(".component-chapter-date")
?.ownTextOrNull()
.parseDate(),
branch = null,
source = source,
)
}
}
private fun String?.parseDate(): Long {
if (this == null) {
assert(false) { "Date is null" }
return 0L
}
val parts = split(' ')
assert(parts.size == 2) { "Wrong date $this" }
val count = parts.getOrNull(0)?.toIntOrNull() ?: return 0L
val unit = parts.getOrNull(1) ?: return 0L
val calendarUnit = when (unit) {
"s" -> Calendar.SECOND
"min" -> Calendar.MINUTE
"h" -> Calendar.HOUR
"j" -> Calendar.DAY_OF_YEAR
"sem." -> Calendar.WEEK_OF_YEAR
"mois" -> Calendar.MONTH
"ans", "an" -> Calendar.YEAR
else -> {
assert(false) { "Unknown time unit $unit" }
return 0L
}
}
val calendar = Calendar.getInstance()
calendar.add(calendarUnit, -count)
return calendar.timeInMillis
}
private fun Element.src(): String {
return attrAsAbsoluteUrlOrNull("data-cfsrc")
?: attrAsAbsoluteUrlOrNull("src")
?: attrAsAbsoluteUrlOrNull("data-src")
?: run {
assert(false) { "Image src not found" }
""
}
}
}

View File

@@ -0,0 +1,252 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONArray
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("BLOGTRUYEN", "BlogTruyen", "vi")
class BlogTruyenParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaSource.BLOGTRUYEN, pageSize = 20) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("blogtruyen.vn", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED)
private val mutex = Mutex()
private val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US)
private var cacheTags: ArrayMap<String, MangaTag>? = null
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val descriptionElement = doc.selectFirstOrThrow("div.description")
val statusText = descriptionElement
.selectFirst("p:contains(Trạng thái) > span.color-red")
?.text()
val state = when (statusText) {
"Đang tiến hành" -> MangaState.ONGOING
"Đã hoàn thành" -> MangaState.FINISHED
else -> null
}
val rating = doc.selectFirst("span.total-vote")?.attr("ng-init")?.let { text ->
val like = text.substringAfter("TotalLike=")
.substringBefore(';')
.toIntOrNull() ?: return@let RATING_UNKNOWN
val dislike = text.substringAfter("TotalDisLike=")
.toIntOrNull() ?: return@let RATING_UNKNOWN
when {
like == 0 && dislike == 0 -> RATING_UNKNOWN
else -> like.toFloat() / (like + dislike)
}
}
val tagMap = getOrCreateTagMap()
val tags = descriptionElement.select("p > span.category").mapNotNullToSet {
val tagName = it.selectFirst("a")?.text()?.trim() ?: return@mapNotNullToSet null
tagMap[tagName]
}
return manga.copy(
tags = tags,
author = descriptionElement.selectFirst("p:contains(Tác giả) > a")?.text(),
description = doc.selectFirst(".detail .content")?.html(),
chapters = parseChapterList(doc),
largeCoverUrl = doc.selectLast("div.thumbnail > img")?.imageUrl(),
state = state,
rating = rating ?: RATING_UNKNOWN,
isNsfw = doc.getElementById("warningCategory") != null,
)
}
private fun parseChapterList(doc: Document): List<MangaChapter> {
val chapterList = doc.select("#list-chapters > p")
return chapterList.mapChapters(reversed = true) { index, element ->
val titleElement = element.selectFirst("span.title > a") ?: return@mapChapters null
val name = titleElement.text()
val relativeUrl = titleElement.attrAsRelativeUrl("href")
val id = relativeUrl.substringAfter('/').substringBefore('/')
val uploadDate = dateFormat.tryParse(element.select("span.publishedDate").text())
MangaChapter(
id = generateUid(id),
name = name,
number = index + 1,
url = relativeUrl,
scanlator = null,
uploadDate = uploadDate,
branch = null,
source = source,
)
}
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
return when {
!query.isNullOrEmpty() -> {
val searchUrl = "https://${domain}/timkiem/nangcao/1/0/-1/-1?txt=${query.urlEncoded()}&p=$page"
val searchContent = webClient.httpGet(searchUrl).parseHtml()
.selectFirst("section.list-manga-bycate > div.list")
parseMangaList(searchContent)
}
!tags.isNullOrEmpty() -> {
val tag = tags.oneOrThrowIfMany()!!
val categoryAjax =
"https://${domain}/ajax/Category/AjaxLoadMangaByCategory?id=${tag.key}&orderBy=5&p=$page"
val listContent = webClient.httpGet(categoryAjax).parseHtml().selectFirst("div.list")
parseMangaList(listContent)
}
else -> getNormalList(page)
}
}
private suspend fun getNormalList(page: Int): List<Manga> {
val pageLink = "https://${domain}/page-$page"
val doc = webClient.httpGet(pageLink).parseHtml()
val listElements = doc.selectFirstOrThrow("section.list-mainpage.listview")
.select("div.bg-white.storyitem")
return listElements.mapNotNull {
val linkTag = it.selectFirst("div.fl-l > a") ?: return@mapNotNull null
val relativeUrl = linkTag.attrAsRelativeUrl("href")
val tagMap = getOrCreateTagMap()
val tags = it.select("footer > div.category > a").mapNotNullToSet { a ->
tagMap[a.text()]
}
Manga(
id = generateUid(relativeUrl),
title = linkTag.attr("title"),
altTitle = null,
description = it.selectFirst("p.al-j.break.line-height-15")?.text(),
url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(domain),
coverUrl = linkTag.selectLast("img")?.imageUrl().orEmpty(),
source = source,
tags = tags,
isNsfw = false,
rating = RATING_UNKNOWN,
author = null,
state = null,
)
}
}
private fun parseMangaList(listElement: Element?): List<Manga> {
listElement ?: return emptyList()
return listElement.select("span.tiptip[data-tiptip]").mapNotNull {
val mangaInfo = listElement.getElementById(it.attr("data-tiptip")) ?: return@mapNotNull null
val a = it.selectFirst("a") ?: return@mapNotNull null
val relativeUrl = a.attrAsRelativeUrl("href")
Manga(
id = generateUid(relativeUrl),
title = a.text(),
altTitle = null,
description = mangaInfo.select("div.al-j.fs-12").text(),
url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(domain),
coverUrl = mangaInfo.selectFirst("div > img.img")?.imageUrl().orEmpty(),
isNsfw = false,
rating = RATING_UNKNOWN,
tags = emptySet(),
author = null,
state = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
fun generateImageId(index: Int) = generateUid("${chapter.url}/$index")
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val pages = ArrayList<MangaPage>()
val referer = chapter.url.toAbsoluteUrl(domain)
doc.select("#content > img").forEach { img ->
pages.add(
MangaPage(
id = generateImageId(pages.size),
url = img.imageUrl(),
preview = null,
source = source,
),
)
}
// Some chapters use js script to render images
val script = doc.selectLast("#content > script")
if (script != null && script.data().contains("listImageCaption")) {
val imagesStr = script.data().substringBefore(';').substringAfterLast('=').trim()
val imageArr = JSONArray(imagesStr)
for (i in 0 until imageArr.length()) {
val imageUrl = imageArr.getJSONObject(i).getString("url")
pages.add(
MangaPage(
id = generateImageId(pages.size),
url = imageUrl,
preview = null,
source = source,
),
)
}
}
return pages
}
override suspend fun getTags(): Set<MangaTag> {
val map = getOrCreateTagMap()
val tags = HashSet<MangaTag>(map.size)
for (entry in map) {
tags.add(entry.value)
}
return tags
}
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
cacheTags?.let { return@withLock it }
val doc = webClient.httpGet("/timkiem/nangcao".toAbsoluteUrl(domain)).parseHtml()
val tagItems = doc.select("li[data-id]")
val tagMap = ArrayMap<String, MangaTag>(tagItems.size)
for (tag in tagItems) {
val title = tag.text().trim()
tagMap[tag.text().trim()] = MangaTag(
title = title,
key = tag.attr("data-id"),
source = source,
)
}
cacheTags = tagMap
tagMap
}
private fun Element.imageUrl(): String {
return attrAsAbsoluteUrlOrNull("src")
?: attrAsAbsoluteUrlOrNull("data-cfsrc")
?: ""
}
}

View File

@@ -0,0 +1,100 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("CLONEMANGA", "CloneManga", "en")
internal class CloneMangaParser(context: MangaLoaderContext) : PagedMangaParser(
context,
MangaSource.CLONEMANGA,
pageSize = 1,
) {
override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.POPULARITY,
)
override val configKeyDomain = ConfigKey.Domain("manga.clone-army.org", null)
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (query != null || page > 1) {
return emptyList()
}
val link = "https://${domain}/viewer_landing.php"
val doc = webClient.httpGet(link).parseHtml()
val mangas = doc.getElementsByClass("comicPreviewContainer")
return mangas.mapNotNull { item ->
val background = item.selectFirstOrThrow(".comicPreview").styleValueOrNull("background")
val href = item.selectFirst("a")?.attrAsAbsoluteUrl("href") ?: return@mapNotNull null
val cover = background?.substring(background.indexOf("site/themes"), background.indexOf(")"))
Manga(
id = generateUid(href),
title = item.selectFirst("h3")?.text() ?: return@mapNotNull null,
coverUrl = "https://${domain}/$cover",
altTitle = null,
author = "Dan Kim",
rating = RATING_UNKNOWN,
url = href,
isNsfw = false,
tags = emptySet(),
state = null,
publicUrl = href.toAbsoluteUrl(domain),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.publicUrl).parseHtml()
val series = doc.location()
val numChapters = Regex(
pattern = "&page=(.*)&lang=",
).findAll(
input = doc.getElementsByTag("script")[3].toString(),
)
.elementAt(3).destructured.component1()
.toInt()
val chapters = ArrayList<MangaChapter>()
for (i in 0..numChapters) {
val chapter = MangaChapter(
id = generateUid("$series&page=$i"),
name = "Chapter ${i + 1}",
number = i + 1,
url = "$series&page=$i",
scanlator = null,
branch = null,
uploadDate = 0L,
source = source,
)
chapters.add(chapter)
}
return manga.copy(chapters = chapters)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val imgUrl = doc.getElementsByClass("subsectionContainer")[0]
.selectFirst("img")
?.attrAsAbsoluteUrlOrNull("src") ?: doc.parseFailed("Something broken")
return listOf(
MangaPage(
id = generateUid(imgUrl),
url = imgUrl,
preview = null,
source = source,
),
)
}
override suspend fun getTags(): Set<MangaTag> = emptySet()
}

View File

@@ -22,9 +22,9 @@ private const val PAGE_SIZE = 20
private const val CHAPTERS_LIMIT = 99999 private const val CHAPTERS_LIMIT = 99999
@MangaSourceParser("COMICK_FUN", "ComicK") @MangaSourceParser("COMICK_FUN", "ComicK")
internal class ComickFunParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.COMICK_FUN) { internal class ComickFunParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.COMICK_FUN) {
override val configKeyDomain = ConfigKey.Domain("comick.fun", null) override val configKeyDomain = ConfigKey.Domain("comick.app", null)
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY, SortOrder.POPULARITY,
@@ -41,11 +41,11 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = domain
val url = buildString { val url = buildString {
append("https://api.") append("https://api.")
append(domain) append(domain)
append("/search?tachiyomi=true") append("/v1.0/search?tachiyomi=true")
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
@@ -71,7 +71,7 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
) )
} }
} }
val ja = context.httpGet(url).parseJsonArray() val ja = webClient.httpGet(url).parseJsonArray()
val tagsMap = cachedTags ?: loadTags() val tagsMap = cachedTags ?: loadTags()
return ja.mapJSON { jo -> return ja.mapJSON { jo ->
val slug = jo.getString("slug") val slug = jo.getString("slug")
@@ -101,9 +101,9 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val domain = getDomain() val domain = domain
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true" val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
val jo = context.httpGet(url).parseJson() val jo = webClient.httpGet(url).parseJson()
val comic = jo.getJSONObject("comic") val comic = jo.getJSONObject("comic")
return manga.copy( return manga.copy(
title = comic.getString("title"), title = comic.getString("title"),
@@ -118,21 +118,20 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
) )
}, },
author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"), author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
chapters = getChapters(comic.getLong("id")), chapters = getChapters(comic.getString("hid")),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val jo = context.httpGet( val jo = webClient.httpGet(
"https://api.${getDomain()}/chapter/${chapter.url}?tachiyomi=true", "https://api.${domain}/chapter/${chapter.url}?tachiyomi=true",
).parseJson().getJSONObject("chapter") ).parseJson().getJSONObject("chapter")
val referer = "https://${getDomain()}/" val referer = "https://${domain}/"
return jo.getJSONArray("images").mapJSON { return jo.getJSONArray("images").mapJSON {
val url = it.getString("url") val url = it.getString("url")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = referer,
preview = null, preview = null,
source = source, source = source,
) )
@@ -149,7 +148,7 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
} }
private suspend fun loadTags(): SparseArrayCompat<MangaTag> { private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
val ja = context.httpGet("https://api.${getDomain()}/genre").parseJsonArray() val ja = webClient.httpGet("https://api.${domain}/genre").parseJsonArray()
val tags = SparseArrayCompat<MangaTag>(ja.length()) val tags = SparseArrayCompat<MangaTag>(ja.length())
for (jo in ja.JSONIterator()) { for (jo in ja.JSONIterator()) {
tags.append( tags.append(
@@ -165,9 +164,9 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
return tags return tags
} }
private suspend fun getChapters(id: Long): List<MangaChapter> { private suspend fun getChapters(hid: String): List<MangaChapter> {
val ja = context.httpGet( val ja = webClient.httpGet(
url = "https://api.${getDomain()}/comic/$id/chapter?tachiyomi=true&limit=$CHAPTERS_LIMIT", url = "https://api.${domain}/comic/$hid/chapters?limit=$CHAPTERS_LIMIT",
).parseJson().getJSONArray("chapters") ).parseJson().getJSONArray("chapters")
val dateFormat = SimpleDateFormat("yyyy-MM-dd") val dateFormat = SimpleDateFormat("yyyy-MM-dd")
val counters = HashMap<Locale, Int>() val counters = HashMap<Locale, Int>()
@@ -213,4 +212,4 @@ internal class ComickFunParser(override val context: MangaLoaderContext) : Manga
} }
return res return res
} }
} }

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArrayMap
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
@@ -13,7 +14,7 @@ import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import java.util.* import java.util.*
@MangaSourceParser("DESUME", "Desu.me", "ru") @MangaSourceParser("DESUME", "Desu.me", "ru")
internal class DesuMeParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DESUME) { internal class DesuMeParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.DESUME, 20) {
override val configKeyDomain = ConfigKey.Domain("desu.me", null) override val configKeyDomain = ConfigKey.Domain("desu.me", null)
@@ -24,23 +25,25 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
) )
override suspend fun getList( private val tagsCache = SuspendLazy(::fetchTags)
offset: Int,
override suspend fun getListPage(
page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (query != null && offset != 0) { if (query != null && page != searchPaginator.firstPage) {
return emptyList() return emptyList()
} }
val domain = getDomain() val domain = domain
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
append("/manga/api/?limit=20&order=") append("/manga/api/?limit=20&order=")
append(getSortKey(sortOrder)) append(getSortKey(sortOrder))
append("&page=") append("&page=")
append((offset / 20) + 1) append(page)
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
append("&genres=") append("&genres=")
appendAll(tags, ",") { it.key } appendAll(tags, ",") { it.key }
@@ -50,13 +53,16 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
append(query) append(query)
} }
} }
val json = context.httpGet(url).parseJson().getJSONArray("response") ?: parseFailed("Invalid response") val json = webClient.httpGet(url).parseJson().getJSONArray("response")
?: throw ParseException("Invalid response", url)
val total = json.length() val total = json.length()
val list = ArrayList<Manga>(total) val list = ArrayList<Manga>(total)
val tagsMap = tagsCache.tryGet().getOrNull()
for (i in 0 until total) { for (i in 0 until total) {
val jo = json.getJSONObject(i) val jo = json.getJSONObject(i)
val cover = jo.getJSONObject("image") val cover = jo.getJSONObject("image")
val id = jo.getLong("id") val id = jo.getLong("id")
val genres = jo.getString("genres").split(',')
list += Manga( list += Manga(
url = "/manga/api/$id", url = "/manga/api/$id",
publicUrl = jo.getString("url"), publicUrl = jo.getString("url"),
@@ -72,7 +78,13 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f), rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
id = generateUid(id), id = generateUid(id),
isNsfw = false, isNsfw = false,
tags = emptySet(), tags = if (!tagsMap.isNullOrEmpty()) {
genres.mapNotNullToSet { g ->
tagsMap[g.trim().toTitleCase()]
}
} else {
emptySet()
},
author = null, author = null,
description = jo.getString("description"), description = jo.getString("description"),
) )
@@ -81,9 +93,9 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val url = manga.url.toAbsoluteUrl(getDomain()) val url = manga.url.toAbsoluteUrl(domain)
val json = context.httpGet(url).parseJson().getJSONObject("response") val json = webClient.httpGet(url).parseJson().getJSONObject("response")
?: throw ParseException("Invalid response") ?: throw ParseException("Invalid response", url)
val baseChapterUrl = manga.url + "/chapter/" val baseChapterUrl = manga.url + "/chapter/"
val chaptersList = json.getJSONObject("chapters").getJSONArray("list") val chaptersList = json.getJSONObject("chapters").getJSONArray("list")
val totalChapters = chaptersList.length() val totalChapters = chaptersList.length()
@@ -116,14 +128,13 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val json = context.httpGet(fullUrl) val json = webClient.httpGet(fullUrl)
.parseJson() .parseJson()
.getJSONObject("response") ?: throw ParseException("Invalid response") .getJSONObject("response") ?: throw ParseException("Invalid response", fullUrl)
return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo -> return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo ->
MangaPage( MangaPage(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
referer = fullUrl,
preview = null, preview = null,
source = chapter.source, source = chapter.source,
url = jo.getString("img"), url = jo.getString("img"),
@@ -132,21 +143,7 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml() return tagsCache.get().values.toSet()
val root = doc.body().getElementById("animeFilter")
?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found")
return root.select("li").mapToSet {
val input = it.selectFirst("input") ?: parseFailed()
MangaTag(
source = source,
key = input.attr("data-genre-slug").ifEmpty {
parseFailed("data-genre-slug is empty")
},
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
parseFailed("data-genre-name is empty")
},
)
}
} }
private fun getSortKey(sortOrder: SortOrder) = private fun getSortKey(sortOrder: SortOrder) =
@@ -157,4 +154,26 @@ internal class DesuMeParser(override val context: MangaLoaderContext) : MangaPar
SortOrder.NEWEST -> "id" SortOrder.NEWEST -> "id"
else -> "updated" else -> "updated"
} }
}
private suspend fun fetchTags(): Map<String, MangaTag> {
val doc = webClient.httpGet("https://${domain}/manga/").parseHtml()
val root = doc.body().requireElementById("animeFilter")
.selectFirstOrThrow(".catalog-genres")
val li = root.select("li")
val result = ArrayMap<String, MangaTag>(li.size)
li.forEach {
val input = it.selectFirstOrThrow("input")
val tag = MangaTag(
source = source,
key = input.attr("data-genre-slug").ifEmpty {
it.parseFailed("data-genre-slug is empty")
},
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
it.parseFailed("data-genre-name is empty")
},
)
result[tag.title] = tag
}
return result
}
}

View File

@@ -0,0 +1,141 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("DOUJINDESU", "DoujinDesu", "id")
class DoujinDesuParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.DOUJINDESU, pageSize = 18) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("212.32.226.234", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY)
override suspend fun getDetails(manga: Manga): Manga {
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().selectFirstOrThrow("#archive")
val chapterDateFormat = SimpleDateFormat("EEEE, dd MMMM yyyy", sourceLocale)
val metadataEl = docs.selectFirst(".wrapper > .metadata tbody")
val state = when (metadataEl?.selectFirst("tr:contains(Status)")?.selectLast("td")?.text()) {
"Finished" -> MangaState.FINISHED
"Publishing" -> MangaState.ONGOING
else -> null
}
return manga.copy(
author = metadataEl?.selectFirst("tr:contains(Author)")?.selectLast("td")?.text(),
description = docs.selectFirst(".wrapper > .metadata > .pb-2")?.selectFirst("p")?.html(),
state = state,
rating = metadataEl?.selectFirst(".rating-prc")?.ownText()?.toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN,
tags = docs.select(".tags > a").mapToSet {
MangaTag(
key = it.attr("title"),
title = it.text(),
source = source,
)
},
chapters = docs.requireElementById("chapter_list")
.select("ul > li")
.mapChapters(reversed = true) { index, element ->
val titleTag = element.selectFirstOrThrow(".epsleft > .lchx > a")
val url = titleTag.attrAsRelativeUrl("href")
MangaChapter(
id = generateUid(url),
name = titleTag.text(),
number = index + 1,
url = url,
scanlator = null,
uploadDate = chapterDateFormat.tryParse(element.select(".epsleft > .date").text()),
branch = null,
source = source,
)
},
)
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val url = urlBuilder().apply {
addPathSegment("manga")
addPathSegment("page")
addPathSegment("$page/")
val order = when (sortOrder) {
SortOrder.UPDATED -> "update"
SortOrder.POPULARITY -> "popular"
SortOrder.ALPHABETICAL -> "title"
SortOrder.NEWEST -> "latest"
else -> throw IllegalArgumentException("Sort order not supported")
}
addQueryParameter("order", order)
addQueryParameter("title", query.orEmpty())
tags?.forEach {
addEncodedQueryParameter("genre[]".urlEncoded(), it.key.urlEncoded())
}
}.build()
return webClient.httpGet(url).parseHtml()
.requireElementById("archives")
.selectFirstOrThrow("div.entries")
.select(".entry")
.map {
val titleTag = it.selectFirstOrThrow(".metadata > a")
val relativeUrl = titleTag.attrAsRelativeUrl("href")
Manga(
id = generateUid(relativeUrl),
title = titleTag.attr("title"),
altTitle = null,
url = relativeUrl,
publicUrl = relativeUrl.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN,
isNsfw = true,
coverUrl = it.selectFirst(".thumbnail > img")?.attrAsAbsoluteUrl("src").orEmpty(),
tags = emptySet(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val id = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
.requireElementById("reader")
.attr("data-id")
return webClient.httpPost("/themes/ajax/ch.php".toAbsoluteUrl(domain), "id=$id").parseHtml()
.select("img")
.map {
val url = it.attrAsRelativeUrl("src")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
return webClient.httpGet("/genre/".toAbsoluteUrl(domain)).parseHtml()
.requireElementById("taxonomy")
.selectFirstOrThrow(".entries")
.select(".entry > a")
.mapToSet {
MangaTag(
key = it.attr("title"),
title = it.attr("title"),
source = source,
)
}
}
}

View File

@@ -1,13 +1,15 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site
import androidx.collection.SparseArrayCompat
import androidx.collection.set
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.util.* import java.util.*
@@ -18,8 +20,8 @@ private const val DOMAIN_AUTHORIZED = "exhentai.org"
@MangaSourceParser("EXHENTAI", "ExHentai") @MangaSourceParser("EXHENTAI", "ExHentai")
internal class ExHentaiParser( internal class ExHentaiParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
) : MangaParser(MangaSource.EXHENTAI), MangaParserAuthProvider { ) : PagedMangaParser(context, MangaSource.EXHENTAI, pageSize = 25), MangaParserAuthProvider {
override val sortOrders: Set<SortOrder> = Collections.singleton( override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.NEWEST, SortOrder.NEWEST,
@@ -29,11 +31,13 @@ internal class ExHentaiParser(
get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null) get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null)
override val authUrl: String override val authUrl: String
get() = "https://${getDomain()}/bounce_login.php" get() = "https://${domain}/bounce_login.php"
private val ratingPattern = Regex("-?[0-9]+px") private val ratingPattern = Regex("-?[0-9]+px")
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash") private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
private var updateDm = false private var updateDm = false
private val nextPages = SparseArrayCompat<Long>()
private val suspiciousContentKey = ConfigKey.ShowSuspiciousContent(true)
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
@@ -55,21 +59,26 @@ internal class ExHentaiParser(
init { init {
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2") context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2") context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
paginator.firstPage = 0
} }
override suspend fun getList( override suspend fun getListPage(
offset: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val page = (offset / 25f).toIntUp()
var search = query?.urlEncoded().orEmpty() var search = query?.urlEncoded().orEmpty()
val next = nextPages.get(page, 0L)
if (page > 0 && next == 0L) {
assert(false) { "Page timestamp not found" }
return emptyList()
}
val url = buildString { val url = buildString {
append("https://") append("https://")
append(getDomain()) append(domain)
append("/?page=") append("/?next=")
append(page) append(next)
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
var fCats = 0 var fCats = 0
for (tag in tags) { for (tag in tags) {
@@ -90,24 +99,29 @@ internal class ExHentaiParser(
if (updateDm) { if (updateDm) {
append("&inline_set=dm_e") append("&inline_set=dm_e")
} }
append("&advsearch=1")
if (config[suspiciousContentKey]) {
append("&f_sh=on")
}
} }
val body = context.httpGet(url).parseHtml().body() val body = webClient.httpGet(url).parseHtml().body()
val root = body.selectFirst("table.itg") val root = body.selectFirst("table.itg")
?.selectFirst("tbody") ?.selectFirst("tbody")
?: if (updateDm) { ?: if (updateDm) {
parseFailed("Cannot find root") body.parseFailed("Cannot find root")
} else { } else {
updateDm = true updateDm = true
return getList(offset, query, tags, sortOrder) return getListPage(page, query, tags, sortOrder)
} }
updateDm = false updateDm = false
nextPages[page + 1] = getNextTimestamp(body)
return root.children().mapNotNull { tr -> return root.children().mapNotNull { tr ->
if (tr.childrenSize() != 2) return@mapNotNull null if (tr.childrenSize() != 2) return@mapNotNull null
val (td1, td2) = tr.children() val (td1, td2) = tr.children()
val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found") val glink = td2.selectFirstOrThrow("div.glink")
val a = glink.parents().select("a").first() ?: parseFailed("link not found") val a = glink.parents().select("a").first() ?: glink.parseFailed("link not found")
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found") val tagsDiv = glink.nextElementSibling() ?: glink.parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div -> val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag( MangaTag(
title = div.text().toTitleCase(), title = div.text().toTitleCase(),
@@ -134,8 +148,8 @@ internal class ExHentaiParser(
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root") val root = doc.body().selectFirstOrThrow("div.gm")
val cover = root.getElementById("gd1")?.children()?.first() val cover = root.getElementById("gd1")?.children()?.first()
val title = root.getElementById("gd2") val title = root.getElementById("gd2")
val taglist = root.getElementById("taglist") val taglist = root.getElementById("taglist")
@@ -158,7 +172,7 @@ internal class ExHentaiParser(
a.text().toIntOrNull() != null a.text().toIntOrNull() != null
}?.let { a -> }?.let { a ->
val count = a.text().toInt() val count = a.text().toInt()
val chapters = ArrayList<MangaChapter>(count) val chapters = ChaptersListBuilder(count)
for (i in 1..count) { for (i in 1..count) {
val url = "${manga.url}?p=${i - 1}" val url = "${manga.url}?p=${i - 1}"
chapters += MangaChapter( chapters += MangaChapter(
@@ -172,20 +186,19 @@ internal class ExHentaiParser(
branch = null, branch = null,
) )
} }
chapters chapters.toList()
}, },
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found") val root = doc.body().requireElementById("gdt")
return root.select("a").map { a -> return root.select("a").map { a ->
val url = a.attrAsRelativeUrl("href") val url = a.attrAsRelativeUrl("href")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = a.absUrl("href"),
preview = null, preview = null,
source = source, source = source,
) )
@@ -193,15 +206,13 @@ internal class ExHentaiParser(
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
return doc.body().getElementById("img")?.absUrl("src") return doc.body().requireElementById("img").attrAsAbsoluteUrl("src")
?: parseFailed("Image not found")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}").parseHtml() val doc = webClient.httpGet("https://${domain}").parseHtml()
val root = doc.body().getElementById("searchbox")?.selectFirst("table") val root = doc.body().requireElementById("searchbox").selectFirstOrThrow("table")
?: parseFailed("Root not found")
return root.select("div.cs").mapNotNullToSet { div -> return root.select("div.cs").mapNotNullToSet { div ->
val id = div.id().substringAfterLast('_').toIntOrNull() val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null ?: return@mapNotNullToSet null
@@ -214,7 +225,7 @@ internal class ExHentaiParser(
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val doc = context.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body() val doc = webClient.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
val username = doc.getElementById("userlinks") val username = doc.getElementById("userlinks")
?.getElementsByAttributeValueContaining("href", "showuser=") ?.getElementsByAttributeValueContaining("href", "showuser=")
?.firstOrNull() ?.firstOrNull()
@@ -222,11 +233,16 @@ internal class ExHentaiParser(
?: if (doc.getElementById("userlinksguest") != null) { ?: if (doc.getElementById("userlinksguest") != null) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} else { } else {
throw ParseException(null) doc.parseFailed()
} }
return username return username
} }
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(suspiciousContentKey)
}
private fun isAuthorized(domain: String): Boolean { private fun isAuthorized(domain: String): Boolean {
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name } val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
return authCookies.all { it in cookies } return authCookies.all { it in cookies }
@@ -280,4 +296,12 @@ internal class ExHentaiParser(
val num = className.drop(2).toIntOrNull(16) ?: return null val num = className.drop(2).toIntOrNull(16) ?: return null
return 2.0.pow(num).toInt().toString() return 2.0.pow(num).toInt().toString()
} }
}
private fun getNextTimestamp(root: Element): Long {
return root.getElementById("unext")
?.attrAsAbsoluteUrlOrNull("href")
?.toHttpUrlOrNull()
?.queryParameter("next")
?.toLongOrNull() ?: 1
}
}

View File

@@ -1,65 +0,0 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.koitharu.kotatsu.parsers.util.toTitleCase
@MangaSourceParser("HENCHAN", "Хентай-тян", "ru")
internal class HenChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.HENCHAN) {
override val configKeyDomain = ConfigKey.Domain(
"xxx.hentaichan.live",
arrayOf("xxx.hentaichan.live", "xx.hentaichan.live", "hentaichan.live", "hentaichan.pro"),
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
return super.getList(offset, query, tags, sortOrder).map {
it.copy(
coverUrl = it.coverUrl.replace("_blur", ""),
isNsfw = true,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
val readLink = manga.url.replace("manga", "online")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source,
)
} ?: manga.tags,
chapters = listOf(
MangaChapter(
id = generateUid(readLink),
url = readLink,
source = source,
number = 1,
uploadDate = 0L,
name = manga.title,
scanlator = null,
branch = null,
),
),
)
}
}

View File

@@ -0,0 +1,223 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArraySet
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
import java.text.SimpleDateFormat
import java.util.*
private const val PAGE_SIZE = 20
private const val INFINITE = 999999
private const val HEADER_ENCODING = "Content-Encoding"
@MangaSourceParser("HONEYMANGA", "Honey Manga", "uk")
class HoneyMangaParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.HONEYMANGA, PAGE_SIZE),
Interceptor {
private val urlApi get() = "https://data.api.$domain"
private val mangaApi get() = "$urlApi/v2/manga/cursor-list"
private val chapterApi get() = "$urlApi/v2/chapter/cursor-list"
private val genresListApi get() = "$urlApi/genres-tags/genres-list"
private val framesApi get() = "$urlApi/chapter/frames"
private val searchApi get() = "https://search.api.$domain/api/v1/title/search-matching?query="
private val imageStorageUrl = "https://manga-storage.fra1.digitaloceanspaces.com/public-resources"
override val headers
get() = Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
.build()
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("honey-manga.com.ua", null)
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.NEWEST,
)
override suspend fun getDetails(manga: Manga): Manga {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
val body = JSONObject()
body.put("mangaId", manga.url)
body.put("pageSize", INFINITE) // Hack lol (no)
body.put("page", 1)
body.put("sortOrder", "ASC")
val chapterRequest = webClient.httpPost(chapterApi, body).parseJson()
return manga.copy(
chapters = chapterRequest.getJSONArray("data").mapJSONIndexed() { i, jo ->
MangaChapter(
id = generateUid(jo.getString("id")),
name = buildString {
append("Том ")
append(jo.optString("volume", "0"))
append(". ")
append("Розділ ")
append(jo.optString("chapterNum", "0"))
if (jo.optString("title") != "Title") {
append(" - ")
append(jo.optString("title"))
}
},
number = i + 1,
url = jo.optString("chapterResourcesId"),
scanlator = null,
uploadDate = dateFormat.tryParse(jo.getString("lastUpdated")),
branch = null,
source = source,
)
},
)
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val body = JSONObject()
body.put("page", page)
body.put("pageSize", PAGE_SIZE)
val sort = JSONObject()
sort.put("sortBy", getSortKey(sortOrder))
sort.put("sortOrder", "DESC")
body.put("sort", sort)
val content = when {
!tags.isNullOrEmpty() -> {
// Tags
val filters = JSONArray()
val tagFilter = JSONObject()
tagFilter.put("filterBy", "genres")
tagFilter.put("filterOperator", "ALL")
val tag = JSONArray()
tags.forEach {
tag.put(it.title)
}
tagFilter.put("filterValue", tag)
filters.put(tagFilter)
body.put("filters", filters)
webClient.httpPost(mangaApi, body).parseJson().getJSONArray("data")
}
!query.isNullOrEmpty() -> {
// Search
when {
query.length < 3 -> throw IllegalArgumentException(
"The query must contain at least 3 characters (Запит має містити щонайменше 3 символи)",
)
page == searchPaginator.firstPage -> webClient
.httpGet(searchApi + query.urlEncoded())
.parseJsonArray()
else -> JSONArray()
}
}
else -> {
// Popular/Newest
body.put("filters", JSONArray())
webClient.httpPost(mangaApi, body).parseJson().getJSONArray("data")
}
}
return content.mapJSON { jo ->
val id = jo.getString("id")
val posterUrl = jo.getString("posterUrl")
Manga(
id = generateUid(id),
title = jo.getString("title"),
altTitle = jo.getStringOrNull("alternativeTitle"),
url = id,
publicUrl = "https://$domain/book/$id",
rating = RATING_UNKNOWN,
isNsfw = isNsfw(jo.getStringOrNull("adult")),
coverUrl = getCoverUrl(posterUrl, 256),
tags = getTitleTags(jo.optJSONArray("genresAndTags")),
state = when (jo.getStringOrNull("titleStatus")) {
"Онгоінг" -> MangaState.ONGOING
"Завершено" -> MangaState.FINISHED
else -> null
},
author = null,
largeCoverUrl = getCoverUrl(posterUrl, 1080),
description = jo.getStringOrNull("description"),
chapters = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val body = JSONObject()
body.put("chapterId", chapter.url)
val content = webClient.httpPost(framesApi, body).parseJson().getJSONObject("resourceIds")
return List(content.length()) { i ->
val item = content.getString(i.toString())
MangaPage(id = generateUid(item), "$imageStorageUrl/$item", getCoverUrl(item, 256), source)
}
}
override suspend fun getTags(): Set<MangaTag> {
// https://data.api.honey-manga.com.ua/genres-tags/genres-list
val content = webClient.httpGet(genresListApi).parseJsonArray()
val tagsSet = ArraySet<MangaTag>(content.length())
repeat(content.length()) { i ->
val item = content.getString(i)
tagsSet.add(MangaTag(item, item, source))
}
return tagsSet
}
// Need for disable encoding (with encoding not working)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val newRequest = if (request.header(HEADER_ENCODING) != null) {
request.newBuilder().removeHeader(HEADER_ENCODING).build()
} else {
request
}
return chain.proceed(newRequest)
}
private fun isNsfw(adultValue: String?): Boolean {
val intValue = adultValue?.removeSuffix('+')?.toIntOrNull()
return intValue != null && intValue >= 18
}
private fun getCoverUrl(id: String, w: Int): String {
// https://honey-manga.com.ua/_next/image?url=https%3A%2F%2Fhoneymangastorage.b-cdn.net%2Fpublic-resources%2F1c4613c2-ffe3-405a-b26a-2cab59ddd223%3Foptimizer%3Dimage%26width%3D512%26height%3D512&w=3840&q=75
return "https://$domain/_next/image?url=https%3A%2F%2Fhoneymangastorage.b-cdn.net%2Fpublic-resources%2F$id&w=$w&q=75"
}
private fun getSortKey(order: SortOrder?) = when (order) {
SortOrder.POPULARITY -> "likes"
SortOrder.NEWEST -> "lastUpdated"
else -> "likes"
}
private fun getTitleTags(jsonTags: JSONArray): Set<MangaTag> {
val tagsSet = ArraySet<MangaTag>(jsonTags.length())
repeat(jsonTags.length()) { i ->
val item = jsonTags.getString(i)
tagsSet.add(MangaTag(title = item.toTitleCase(sourceLocale), key = item, source = source))
}
return tagsSet
}
}

View File

@@ -0,0 +1,193 @@
package org.koitharu.kotatsu.parsers.site
import okhttp3.Headers
import org.json.JSONObject
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("JAPSCAN", "JapScan", "fr")
internal class JapScanParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.JAPSCAN, 30) {
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL)
override val configKeyDomain = ConfigKey.Domain("www.japscan.lol", arrayOf("www.japscan.lol", "japscan.ws"))
override val headers: Headers = Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0")
.build()
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
require(tags.isNullOrEmpty()) { "Tags are not supported by this source" }
if (!query.isNullOrEmpty()) {
return if (page == paginator.firstPage) getListPageSearch(query) else emptyList()
}
val url = urlBuilder()
.addPathSegment("mangas")
.addPathSegment(page.toString())
.build()
val root = webClient.httpGet(url).parseHtml()
.requireElementById("main")
.selectFirstOrThrow(".flex-wrap")
return root.select(".mainTitle")
.map { p ->
val div = checkNotNull(p.parent())
val a = div.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href")
Manga(
id = generateUid(href),
title = p.text(),
altTitle = null,
url = href,
publicUrl = href.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = div.selectFirstOrThrow("img").attrAsAbsoluteUrl("src"),
tags = setOf(),
state = null,
author = null,
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().requireElementById("main")
val dateFormat = SimpleDateFormat("dd MMM yyyy", Locale.ENGLISH)
return manga.copy(
altTitle = root.tableValue("Nom Original:"),
isNsfw = root.tableValue("Âge conseillé :")?.extractIntOrNull().let { it != null && it >= 18 },
tags = root.tableValue("Type(s):")?.split(", ")?.mapNotNullToSet {
it.toTag()
}.orEmpty() + root.tableValue("Genre(s):")?.split(", ")?.mapNotNullToSet {
it.toTag()
}.orEmpty(),
state = when (root.tableValue("Statut:")) {
"En Cours" -> MangaState.ONGOING
"Terminé", "Abondonné" -> MangaState.FINISHED
else -> null
},
author = root.tableValue("Artiste(s):")?.substringBefore(','),
description = root.selectFirst("p.list-group-item-primary")?.html(),
chapters = root.requireElementById("chapters_list")
.select("div.chapters_list")
.mapChapters(reversed = true) { i, div ->
val a = div.selectFirst("a") ?: return@mapChapters null
val href = a.attrAsRelativeUrl("href")
MangaChapter(
id = generateUid(href),
name = a.text(),
number = i,
url = href,
scanlator = null,
uploadDate = dateFormat.tryParse(div.selectFirst("span.float-right")?.text()),
branch = null,
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(chapterUrl).parseHtml()
val scriptUrl = doc.getElementsByTag("script").firstNotNullOf { script ->
script.attrAsAbsoluteUrlOrNull("src")?.takeIf { it.contains("/zjs/") }
}
val embeddedData = doc.requireElementById("data").attr("data-data")
val script = webClient.httpGet(scriptUrl).parseRaw()
val sample = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toList()
val keyRegex = Regex("""'([\dA-Z]{62})'""", RegexOption.IGNORE_CASE)
val keyTables = keyRegex.findAll(script)
.mapNotNullTo(ArrayList(2)) { match ->
match.groupValues[1].takeIf {
it.toList().sorted() == sample
}
}
check(keyTables.size == 2)
var error: Exception? = null
repeat(2) { i ->
val key = keyTables[i].zip(keyTables[1 - i]).toMap()
try {
val unscrambledData = embeddedData.map { key[it] ?: it }.joinToString("")
if (unscrambledData.startsWith("ey")) {
val array = JSONObject(context.decodeBase64(unscrambledData).toString(Charsets.UTF_8))
.getJSONArray("imagesLink")
val result = ArrayList<MangaPage>(array.length())
repeat(array.length()) { index ->
val url = array.getString(index)
result += MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
return result
}
} catch (e: Exception) {
error = e
}
}
throw (error ?: ParseException("Cannot decode pages list", chapterUrl))
}
override suspend fun getTags(): Set<MangaTag> {
return emptySet() // not supported
}
private suspend fun getListPageSearch(
query: String,
): List<Manga> {
val json = webClient.httpPost(
"https://$domain/live-search/",
mapOf("search" to query.urlEncoded()),
).parseJsonArray()
return json.mapJSON { jo ->
val url = jo.getString("url")
Manga(
id = generateUid(url),
title = jo.getString("name"),
altTitle = jo.getStringOrNull("alternate_names")?.substringBefore(','),
url = url,
publicUrl = url.toAbsoluteUrl(domain),
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = jo.getString("image").toAbsoluteUrl(domain),
tags = emptySet(),
state = null,
author = null,
source = source,
)
}
}
private fun Element.tableValue(label: String): String? {
return getElementsMatchingOwnText(label).firstOrNull()?.parent()?.ownTextOrNull()
}
private fun String.extractIntOrNull(): Int? = this.filter(Char::isDigit).toIntOrNull()
private fun String.toTag() = MangaTag(
title = this.toTitleCase(sourceLocale),
key = this.replace(' ', '-').lowercase(sourceLocale),
source = source,
)
}

View File

@@ -1,12 +1,15 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.* import org.koitharu.kotatsu.parsers.util.json.*
@@ -14,13 +17,16 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
private const val PAGE_SIZE = 20 private const val PAGE_SIZE = 20
private const val CHAPTERS_PAGE_SIZE = 60 private const val CHAPTERS_FIRST_PAGE_SIZE = 120
private const val CHAPTERS_MAX_PAGE_SIZE = 500
private const val CHAPTERS_PARALLELISM = 3
private const val CHAPTERS_MAX_COUNT = 10_000 // strange api behavior, looks like a bug
private const val CONTENT_RATING = private const val CONTENT_RATING =
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic" "contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
private const val LOCALE_FALLBACK = "en" private const val LOCALE_FALLBACK = "en"
@MangaSourceParser("MANGADEX", "MangaDex") @MangaSourceParser("MANGADEX", "MangaDex")
internal class MangaDexParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGADEX) { internal class MangaDexParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGADEX) {
override val configKeyDomain = ConfigKey.Domain("mangadex.org", null) override val configKeyDomain = ConfigKey.Domain("mangadex.org", null)
@@ -37,7 +43,7 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = domain
val url = buildString { val url = buildString {
append("https://api.") append("https://api.")
append(domain) append(domain)
@@ -62,6 +68,7 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
when (sortOrder) { when (sortOrder) {
SortOrder.UPDATED, SortOrder.UPDATED,
-> "[latestUploadedChapter]=desc" -> "[latestUploadedChapter]=desc"
SortOrder.ALPHABETICAL -> "[title]=asc" SortOrder.ALPHABETICAL -> "[title]=asc"
SortOrder.NEWEST -> "[createdAt]=desc" SortOrder.NEWEST -> "[createdAt]=desc"
SortOrder.POPULARITY -> "[followedCount]=desc" SortOrder.POPULARITY -> "[followedCount]=desc"
@@ -69,7 +76,7 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
}, },
) )
} }
val json = context.httpGet(url).parseJson().getJSONArray("data") val json = webClient.httpGet(url).parseJson().getJSONArray("data")
return json.mapJSON { jo -> return json.mapJSON { jo ->
val id = jo.getString("id") val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes") val attrs = jo.getJSONObject("attributes")
@@ -117,13 +124,14 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
} }
override suspend fun getDetails(manga: Manga): Manga = coroutineScope { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val domain = getDomain() val domain = domain
val mangaId = manga.url.removePrefix("/")
val attrsDeferred = async { val attrsDeferred = async {
context.httpGet( webClient.httpGet(
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art", "https://api.$domain/manga/${mangaId}?includes[]=artist&includes[]=author&includes[]=cover_art",
).parseJson().getJSONObject("data").getJSONObject("attributes") ).parseJson().getJSONObject("data").getJSONObject("attributes")
} }
val feedDeferred = async { loadChapters(manga) } val feedDeferred = async { loadChapters(mangaId) }
val mangaAttrs = attrsDeferred.await() val mangaAttrs = attrsDeferred.await()
val feed = feedDeferred.await() val feed = feedDeferred.await()
// 2022-01-02T00:27:11+00:00 // 2022-01-02T00:27:11+00:00
@@ -132,22 +140,22 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
Locale.ROOT, Locale.ROOT,
) )
manga.copy( manga.copy(
description = mangaAttrs.getJSONObject("description").selectByLocale() description = mangaAttrs.optJSONObject("description")?.selectByLocale()
?: manga.description, ?: manga.description,
chapters = feed.mapNotNull { jo -> chapters = feed.mapChapters { _, jo ->
val id = jo.getString("id") val id = jo.getString("id")
val attrs = jo.getJSONObject("attributes") val attrs = jo.getJSONObject("attributes")
if (!attrs.isNull("externalUrl")) { if (!attrs.isNull("externalUrl")) {
return@mapNotNull null return@mapChapters null
} }
val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) } val locale = attrs.getStringOrNull("translatedLanguage")?.let { Locale.forLanguageTag(it) }
val relations = jo.getJSONArray("relationships").associateByKey("type") val relations = jo.getJSONArray("relationships").associateByKey("type")
val number = attrs.getIntOrDefault("chapter", 0) val number = attrs.getFloatOrDefault("chapter", 0f)
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty) name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
?: "Chapter #$number", ?: "Chapter #$number",
number = number, number = number.toInt(),
url = id, url = id,
scanlator = relations["scanlation_group"]?.getStringOrNull("name"), scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")), uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
@@ -159,19 +167,17 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val domain = getDomain() val domain = domain
val chapterJson = context.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false") val chapterJson = webClient.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
.parseJson() .parseJson()
.getJSONObject("chapter") .getJSONObject("chapter")
val pages = chapterJson.getJSONArray("data") val pages = chapterJson.getJSONArray("data")
val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/" val prefix = "https://uploads.$domain/data/${chapterJson.getString("hash")}/"
val referer = "https://$domain/"
return List(pages.length()) { i -> return List(pages.length()) { i ->
val url = prefix + pages.getString(i) val url = prefix + pages.getString(i)
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = referer,
preview = null, // TODO prefix + dataSaver.getString(i), preview = null, // TODO prefix + dataSaver.getString(i),
source = source, source = source,
) )
@@ -179,7 +185,7 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val tags = context.httpGet("https://api.${getDomain()}/manga/tag").parseJson() val tags = webClient.httpGet("https://api.${domain}/manga/tag").parseJson()
.getJSONArray("data") .getJSONArray("data")
return tags.mapJSONToSet { jo -> return tags.mapJSONToSet { jo ->
MangaTag( MangaTag(
@@ -201,30 +207,67 @@ internal class MangaDexParser(override val context: MangaLoaderContext) : MangaP
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
} }
private suspend fun loadChapters(manga: Manga): List<JSONObject> { private suspend fun loadChapters(mangaId: String): List<JSONObject> {
val domain = getDomain() val firstPage = loadChapters(mangaId, offset = 0, limit = CHAPTERS_FIRST_PAGE_SIZE)
val result = ArrayList<JSONObject>() if (firstPage.size >= firstPage.total) {
while (true) { return firstPage.data
val url = buildString {
append("https://api.")
append(domain)
append("/manga/")
append(manga.url)
append("/feed")
append("?limit=")
append(CHAPTERS_PAGE_SIZE)
append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=")
append(result.size)
append('&')
append(CONTENT_RATING)
}
val json = context.httpGet(url).parseJson()
val data = json.optJSONArray("data")
if (data.isNullOrEmpty()) {
break
}
data.mapJSONTo(result) { it }
} }
val tail = coroutineScope {
val leftCount = firstPage.total.coerceAtMost(CHAPTERS_MAX_COUNT) - firstPage.size
val pages = (leftCount / CHAPTERS_MAX_PAGE_SIZE.toFloat()).toIntUp()
val dispatcher = Dispatchers.Default.limitedParallelism(CHAPTERS_PARALLELISM)
List(pages) { page ->
val offset = page * CHAPTERS_MAX_PAGE_SIZE + firstPage.size
async(dispatcher) {
loadChapters(mangaId, offset, CHAPTERS_MAX_PAGE_SIZE)
}
}.awaitAll()
}
val result = ArrayList<JSONObject>(firstPage.total)
result += firstPage.data
tail.flatMapTo(result) { it.data }
return result return result
} }
}
private suspend fun loadChapters(mangaId: String, offset: Int, limit: Int): Chapters {
val limitedLimit = when {
offset >= CHAPTERS_MAX_COUNT -> return Chapters(emptyList(), CHAPTERS_MAX_COUNT)
offset + limit > CHAPTERS_MAX_COUNT -> CHAPTERS_MAX_COUNT - offset
else -> limit
}
val url = buildString {
append("https://api.")
append(domain)
append("/manga/")
append(mangaId)
append("/feed")
append("?limit=")
append(limitedLimit)
append("&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=")
append(offset)
append('&')
append(CONTENT_RATING)
}
val json = webClient.httpGet(url).parseJson()
if (json.getString("result") == "ok") {
return Chapters(
data = json.optJSONArray("data")?.toJSONList().orEmpty(),
total = json.getInt("total"),
)
} else {
val error = json.optJSONArray("errors").mapJSON { jo ->
jo.getString("detail")
}.joinToString("\n")
throw ParseException(error, url)
}
}
private class Chapters(
val data: List<JSONObject>,
val total: Int,
) {
val size: Int
get() = data.size
}
}

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
@@ -12,37 +12,41 @@ import java.util.*
private const val DEF_BRANCH_NAME = "Основний переклад" private const val DEF_BRANCH_NAME = "Основний переклад"
@MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk") @MangaSourceParser("MANGAINUA", "MANGA/in/UA", "uk")
class MangaInUaParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGAINUA) { class MangaInUaParser(context: MangaLoaderContext) : PagedMangaParser(
context = context,
source = MangaSource.MANGAINUA,
pageSize = 24,
searchPageSize = 10,
) {
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = Collections.singleton(SortOrder.UPDATED) get() = Collections.singleton(SortOrder.UPDATED)
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("manga.in.ua", null) override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("manga.in.ua", null)
override suspend fun getList( override suspend fun getListPage(
offset: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val page = (offset / 24f).toIntUp().inc()
val searchPage = (offset / 10f).toIntUp().inc()
val url = when { val url = when {
!query.isNullOrEmpty() -> ( !query.isNullOrEmpty() -> (
"/index.php?do=search" + "/index.php?do=search" +
"&subaction=search" + "&subaction=search" +
"&search_start=$searchPage" + "&search_start=$page" +
"&full_search=1" + "&full_search=1" +
"&story=$query" + "&story=$query" +
"&titleonly=3" "&titleonly=3"
).toAbsoluteUrl(getDomain()) ).toAbsoluteUrl(domain)
tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(getDomain())
tags.isNullOrEmpty() -> "/mangas/page/$page".toAbsoluteUrl(domain)
tags.size == 1 -> "${tags.first().key}/page/$page" tags.size == 1 -> "${tags.first().key}/page/$page"
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre") tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 genre")
else -> "/mangas/page/$page".toAbsoluteUrl(getDomain()) else -> "/mangas/page/$page".toAbsoluteUrl(domain)
} }
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val container = doc.body().getElementById("dle-content") ?: parseFailed("Container not found") val container = doc.body().requireElementById("site-content")
val items = container.select("div.col-6") val items = container.select("div.col-6")
return items.mapNotNull { item -> return items.mapNotNull { item ->
val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null val href = item.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
@@ -70,36 +74,33 @@ class MangaInUaParser(override val context: MangaLoaderContext) : MangaParser(Ma
} }
}.getOrNull().orEmpty(), }.getOrNull().orEmpty(),
state = null, state = null,
publicUrl = href.toAbsoluteUrl(container.host ?: getDomain()), publicUrl = href.toAbsoluteUrl(container.host ?: domain),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = val root = doc.body().requireElementById("site-content")
doc.body().getElementById("dle-content") ?: parseFailed("Cannot find root")
val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US) val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US)
val chapterNodes = root.selectFirstOrThrow(".linkstocomics") val chapterNodes = root.selectFirstOrThrow(".linkstocomics").select(".ltcitems")
.select(".ltcitems")
.filterNot { x -> x.selectFirst(".foruserreadedbar") != null }
var prevChapterName: String? = null var prevChapterName: String? = null
var i = 0 var i = 0
return manga.copy( return manga.copy(
description = root.selectFirst("div.item__full-description")?.text(), description = root.selectFirst("div.item__full-description")?.text(),
largeCoverUrl = root.selectFirst("div.item__full-sidebar--poster")?.selectFirst("img") largeCoverUrl = root.selectFirst("div.item__full-sidebar--poster")?.selectFirst("img")
?.attrAsAbsoluteUrlOrNull("src"), ?.attrAsAbsoluteUrlOrNull("src"),
chapters = chapterNodes.mapNotNull { item -> chapters = chapterNodes.mapChapters { _, item ->
val href = item?.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = item?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapNotNull null ?: return@mapChapters null
val isAlternative = item.styleValueOrNull("background") != null val isAlternative = item.styleValueOrNull("background") != null
val name = item.selectFirst("a")?.text().orEmpty() val name = item.selectFirst("a")?.text().orEmpty()
if (!isAlternative) i++ if (!isAlternative) i++
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = if (isAlternative) { name = if (isAlternative) {
prevChapterName ?: return@mapNotNull null prevChapterName ?: return@mapChapters null
} else { } else {
prevChapterName = name prevChapterName = name
name name
@@ -120,28 +121,25 @@ class MangaInUaParser(override val context: MangaLoaderContext) : MangaParser(Ma
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val root = val root = doc.body().requireElementById("comics").selectFirstOrThrow("ul.xfieldimagegallery")
doc.body().getElementById("comics")?.selectFirst("ul.xfieldimagegallery") ?: parseFailed("Root not found")
return root.select("li").map { ul -> return root.select("li").map { ul ->
val img = ul.selectFirst("img") ?: parseFailed("Page image not found") val img = ul.selectFirstOrThrow("img")
val url = img.attrAsAbsoluteUrl("data-src") val url = img.attrAsAbsoluteUrl("data-src")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain() val domain = domain
val doc = context.httpGet("https://$domain/mangas").parseHtml() val doc = webClient.httpGet("https://$domain/mangas").parseHtml()
val root = val root = doc.body().requireElementById("menu_1").selectFirstOrThrow("div.menu__wrapper")
doc.body().getElementById("menu_1")?.selectFirst("div.menu__wrapper") ?: parseFailed("Cannot find root")
return root.select("li").mapNotNullToSet { li -> return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null val a = li.selectFirst("a") ?: return@mapNotNullToSet null
MangaTag( MangaTag(
@@ -151,4 +149,4 @@ class MangaInUaParser(override val context: MangaLoaderContext) : MangaParser(Ma
) )
} }
} }
} }

View File

@@ -1,185 +0,0 @@
package org.koitharu.kotatsu.parsers.site
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("MANGAOWL", "MangaOwl", "en")
internal class MangaOwlParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGAOWL) {
override val configKeyDomain = ConfigKey.Domain("mangaowls.com", null)
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.UPDATED,
)
private val regexNsfw = Regex("(yaoi)|(yuri)|(smut)|(mature)|(adult)", RegexOption.IGNORE_CASE)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val page = (offset / 36f).toIntUp().inc()
val link = buildString {
append("https://")
append(getDomain())
when {
!query.isNullOrEmpty() -> {
append("/search/$page?search=")
append(query.urlEncoded())
}
!tags.isNullOrEmpty() -> {
for (tag in tags) {
append(tag.key)
}
append("/$page?type=${getAlternativeSortKey(sortOrder)}")
}
else -> {
append("/${getSortKey(sortOrder)}/$page")
}
}
}
val doc = context.httpGet(link).parseHtml()
val slides = doc.body().select("ul.slides") ?: parseFailed("An error occurred while parsing")
val items = slides.select("div.col-md-2")
return items.mapNotNull { item ->
val href = item.selectFirst("h6 a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null
Manga(
id = generateUid(href),
title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
altTitle = null,
author = null,
rating = runCatching {
item.selectFirst("div.block-stars")
?.text()
?.toFloatOrNull()
?.div(10f)
}.getOrNull() ?: RATING_UNKNOWN,
url = href,
isNsfw = false,
tags = emptySet(),
state = null,
publicUrl = href.toAbsoluteUrl(getDomain()),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.publicUrl).parseHtml()
val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE)
val trElement =
doc.getElementsByTag("script").find { trRegex.find(it.data()) != null } ?: parseFailed("Oops, tr not found")
val tr = trRegex.find(trElement.data())!!.groups[1]!!.value
val s = context.encodeBase64(getDomain().toByteArray())
var isNsfw = manga.isNsfw
val parsedTags = info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
.mapNotNullToSet {
val a = it.selectFirst("a") ?: return@mapNotNullToSet null
val name = a.text()
if (!isNsfw && isNsfwGenre(name)) {
isNsfw = true
}
MangaTag(
title = name.toTitleCase(),
key = a.attr("href"),
source = source,
)
}
return manga.copy(
description = info.selectFirst(".description")?.html(),
largeCoverUrl = info.select("img").first()?.let { img ->
if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
},
isNsfw = isNsfw,
author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
tags = manga.tags + parsedTags,
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list")
.asReversed().mapIndexed { i, li ->
val a = li.select("a")
val href = a.attr("data-href").ifEmpty {
parseFailed("Link is missing")
}
MangaChapter(
id = generateUid(href),
name = a.select("label").text(),
number = i + 1,
url = "$href?tr=$tr&s=$s",
scanlator = null,
branch = null,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
source = MangaSource.MANGAOWL,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
val doc = context.httpGet(fullUrl).parseHtml()
val root = doc.body().select("div.item img.owl-lazy") ?: throw ParseException("Root not found")
return root.map { div ->
val url = div?.attrAsRelativeUrlOrNull("data-src") ?: parseFailed("Page image not found")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
referer = url,
source = MangaSource.MANGAOWL,
)
}
}
private fun parseStatus(status: String?) = when {
status == null -> null
status.contains("Ongoing") -> MangaState.ONGOING
status.contains("Completed") -> MangaState.FINISHED
else -> null
}
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/").parseHtml()
val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
return root.mapToSet { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href"),
source = source,
)
}
}
private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.POPULARITY -> "popular"
SortOrder.NEWEST -> "new_release"
SortOrder.UPDATED -> "lastest"
else -> "lastest"
}
private fun getAlternativeSortKey(sortOrder: SortOrder) =
when (sortOrder) {
SortOrder.POPULARITY -> "0"
SortOrder.NEWEST -> "2"
SortOrder.UPDATED -> "3"
else -> "3"
}
private fun isNsfwGenre(name: String): Boolean = regexNsfw.containsMatchIn(name)
}

View File

@@ -4,7 +4,6 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.DateFormat import java.text.DateFormat
@@ -12,7 +11,7 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
@MangaSourceParser("MANGATOWN", "MangaTown", "en") @MangaSourceParser("MANGATOWN", "MangaTown", "en")
internal class MangaTownParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.MANGATOWN) { internal class MangaTownParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.MANGATOWN) {
override val configKeyDomain = ConfigKey.Domain("www.mangatown.com", null) override val configKeyDomain = ConfigKey.Domain("www.mangatown.com", null)
@@ -43,19 +42,19 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
if (offset != 0) { if (offset != 0) {
return emptyList() return emptyList()
} }
"/search?name=${query.urlEncoded()}".toAbsoluteUrl(getDomain()) "/search?name=${query.urlEncoded()}".toAbsoluteUrl(domain)
} }
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".toAbsoluteUrl(getDomain())
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".toAbsoluteUrl(getDomain()) tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".toAbsoluteUrl(domain)
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".toAbsoluteUrl(domain)
else -> tags.joinToString( else -> tags.joinToString(
prefix = "/search?page=$page".toAbsoluteUrl(getDomain()), prefix = "/search?page=$page".toAbsoluteUrl(domain),
) { tag -> ) { tag ->
"&genres[${tag.key}]=1" "&genres[${tag.key}]=1"
} }
} }
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.manga_pic_list") val root = doc.body().selectFirstOrThrow("ul.manga_pic_list")
?: throw ParseException("Root not found")
return root.select("li").mapNotNull { li -> return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover") val a = li.selectFirst("a.manga_cover")
val href = a?.attrAsRelativeUrlOrNull("href") val href = a?.attrAsRelativeUrlOrNull("href")
@@ -88,15 +87,15 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
}.orEmpty(), }.orEmpty(),
url = href, url = href,
isNsfw = false, isNsfw = false,
publicUrl = href.toAbsoluteUrl(a.host ?: getDomain()), publicUrl = href.toAbsoluteUrl(a.host ?: domain),
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().selectFirst("section.main") val root = doc.body().selectFirstOrThrow("section.main")
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root") .selectFirstOrThrow("div.article_content")
val info = root.selectFirst("div.detail_info")?.selectFirst("ul") val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content") val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
@@ -112,9 +111,9 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
) )
}.orEmpty(), }.orEmpty(),
description = info?.getElementById("show")?.ownText(), description = info?.getElementById("show")?.ownText(),
chapters = chaptersList?.mapIndexedNotNull { i, li -> chapters = chaptersList?.mapChapters { i, li ->
val href = li.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = li.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapIndexedNotNull null ?: return@mapChapters null
val name = li.select("span") val name = li.select("span")
.filter { x -> x.className().isEmpty() } .filter { x -> x.className().isEmpty() }
.joinToString(" - ") { it.text() }.trim() .joinToString(" - ") { it.text() }.trim()
@@ -136,11 +135,10 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.page_select") val root = doc.body().selectFirstOrThrow("div.page_select")
?: throw ParseException("Cannot find root") return root.selectFirstOrThrow("select").selectOrThrow("option").mapNotNull {
return root.selectFirst("select")?.select("option")?.mapNotNull {
val href = it.attrAsRelativeUrlOrNull("value") val href = it.attrAsRelativeUrlOrNull("value")
if (href == null || href.endsWith("featured.html")) { if (href == null || href.endsWith("featured.html")) {
return@mapNotNull null return@mapNotNull null
@@ -149,23 +147,22 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
id = generateUid(href), id = generateUid(href),
url = href, url = href,
preview = null, preview = null,
referer = fullUrl,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
) )
} ?: parseFailed("Pages list not found") }
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
return doc.getElementById("image")?.absUrl("src") ?: parseFailed("Image not found") return doc.requireElementById("image").attrAsAbsoluteUrl("src")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("/directory/".toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().selectFirst("aside.right") val root = doc.body().selectFirst("aside.right")
?.getElementsContainingOwnText("Genres") ?.getElementsContainingOwnText("Genres")
?.first() ?.first()
?.nextElementSibling() ?: parseFailed("Root not found") ?.nextElementSibling() ?: doc.parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li -> return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val key = a.attr("href").parseTagKey() val key = a.attr("href").parseTagKey()
@@ -190,7 +187,7 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
} }
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> { private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain("m"))).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("m"))).parseHtml()
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList() val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return list.select("li").asReversed().mapIndexedNotNull { i, li -> return list.select("li").asReversed().mapIndexedNotNull { i, li ->
@@ -216,4 +213,4 @@ internal class MangaTownParser(override val context: MangaLoaderContext) : Manga
} }
private fun String.parseTagKey() = split('/').findLast { regexTag matches it } private fun String.parseTagKey() = split('/').findLast { regexTag matches it }
} }

View File

@@ -0,0 +1,196 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArrayMap
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("MANHWA18", "Manhwa18", "en")
class Manhwa18Parser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaSource.MANHWA18, pageSize = 20, searchPageSize = 20) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("manhwa18.net", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.ALPHABETICAL, SortOrder.NEWEST)
private val tagsMap = SuspendLazy(::parseTags)
override suspend fun getFavicons(): Favicons {
return Favicons(
listOf(
Favicon("https://${domain}/uploads/logos/logo-mini.png", 92, null),
),
domain,
)
}
override suspend fun getDetails(manga: Manga): Manga {
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val cardInfoElement = docs.selectFirst(".card .manga-info")
val author = cardInfoElement?.selectFirst("b:contains(Author(s))")?.parent()
?.select("a.btn")
?.joinToString(", ") { it.text() }
val availableTags = tagsMap.get()
val tags = cardInfoElement?.selectFirst("b:contains(Genre(s))")?.parent()
?.select("a.btn")
?.mapNotNullToSet { availableTags[it.text().lowercase(Locale.ENGLISH)] }
val state = cardInfoElement?.selectFirst("b:contains(Status)")?.parent()
?.selectFirst("a.btn")
?.let {
when (it.text()) {
"On going" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
else -> null
}
}
return manga.copy(
altTitle = cardInfoElement?.selectFirst("b:contains(Other names)")?.parent()?.ownText()?.removePrefix(": "),
author = author,
description = docs.selectFirst(".series-summary .summary-content")?.html(),
tags = tags.orEmpty(),
state = state,
chapters = docs.select(".card-body > .list-chapters > a").mapChapters(reversed = true) { index, element ->
// attrAsRelativeUrl only return page url without the '/'
val chapterUrl = element.attrAsAbsoluteUrlOrNull("href")?.toRelativeUrl(domain)
?: return@mapChapters null
val uploadDate = parseUploadDate(element.selectFirst(".chapter-time")?.text())
MangaChapter(
id = generateUid(chapterUrl),
name = element.selectFirst(".chapter-name")?.text().orEmpty(),
number = index + 1,
url = chapterUrl,
scanlator = null,
uploadDate = uploadDate,
branch = null,
source = MangaSource.MANHWA18,
)
},
)
}
// 7 minutes ago
// 5 hours ago
// 2 days ago
// 2 weeks ago
// 4 years ago
private fun parseUploadDate(timeStr: String?): Long {
timeStr ?: return 0
val timeWords = timeStr.split(' ')
if (timeWords.size != 3) return 0
val timeWord = timeWords[1]
val timeAmount = timeWords[0].toIntOrNull() ?: return 0
val timeUnit = when (timeWord) {
"minute", "minutes" -> Calendar.MINUTE
"hour", "hours" -> Calendar.HOUR
"day", "days" -> Calendar.DAY_OF_YEAR
"week", "weeks" -> Calendar.WEEK_OF_YEAR
"month", "months" -> Calendar.MONTH
"year", "years" -> Calendar.YEAR
else -> return 0
}
val cal = Calendar.getInstance()
cal.add(timeUnit, -timeAmount)
return cal.time.time
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val sortQuery = when (sortOrder) {
SortOrder.ALPHABETICAL -> "az"
SortOrder.POPULARITY -> "top"
SortOrder.UPDATED -> "update"
SortOrder.NEWEST -> "new"
else -> ""
}
val tagQuery = tags?.joinToString(",") { it.key }.orEmpty()
val url = buildString {
append("https://")
append(domain)
append("/tim-kiem?page=")
append(page)
if (!query.isNullOrEmpty()) {
append("&q=")
append(query.urlEncoded())
}
append("&accept_genres=$tagQuery")
append("&sort=")
append(sortQuery)
}
val docs = webClient.httpGet(url).parseHtml()
return docs.select(".card-body .thumb-item-flow")
.map {
val titleElement = it.selectFirstOrThrow(".thumb_attr.series-title > a")
val absUrl = titleElement.attrAsAbsoluteUrl("href")
Manga(
id = generateUid(absUrl.toRelativeUrl(domain)),
title = titleElement.text(),
altTitle = null,
url = absUrl.toRelativeUrl(domain),
publicUrl = absUrl,
rating = RATING_UNKNOWN,
isNsfw = true,
coverUrl = it.selectFirst("div.img-in-ratio")?.attrAsAbsoluteUrl("data-bg").orEmpty(),
tags = emptySet(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
source = MangaSource.MANHWA18,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(chapterUrl).parseHtml()
return doc.requireElementById("chapter-content").select("img").mapNotNull {
val url = it.attrAsRelativeUrlOrNull("data-src")
?: it.attrAsRelativeUrlOrNull("src")
?: return@mapNotNull null
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = MangaSource.MANHWA18,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
return tagsMap.get().values.toSet()
}
private suspend fun parseTags(): Map<String, MangaTag> {
val doc = webClient.httpGet("https://$domain/tim-kiem?q=").parseHtml()
val list = doc.getElementsByAttribute("data-genre-id")
if (list.isEmpty()) {
return emptyMap()
}
val result = ArrayMap<String, MangaTag>(list.size)
for (item in list) {
val id = item.attr("data-genre-id")
val name = item.text()
result[name.lowercase(Locale.ENGLISH)] = MangaTag(
title = name.toTitleCase(Locale.ENGLISH),
key = id,
source = source,
)
}
return result
}
}

View File

@@ -6,8 +6,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
@@ -15,7 +15,7 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
@MangaSourceParser("NHENTAI", "N-Hentai") @MangaSourceParser("NHENTAI", "N-Hentai")
class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.NHENTAI) { class NHentaiParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.NHENTAI, pageSize = 25) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("nhentai.net", null) get() = ConfigKey.Domain("nhentai.net", null)
@@ -23,17 +23,16 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.NEWEST, SortOrder.POPULARITY) get() = EnumSet.of(SortOrder.NEWEST, SortOrder.POPULARITY)
override suspend fun getList( override suspend fun getListPage(
offset: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (query.isNullOrEmpty() && tags != null && tags.size > 1) { if (query.isNullOrEmpty() && tags != null && tags.size > 1) {
return getList(offset, buildQuery(tags), emptySet(), sortOrder) return getListPage(page, buildQuery(tags), emptySet(), sortOrder)
} }
val domain = getDomain() val domain = domain
val page = (offset / 25) + 1
val url = buildString { val url = buildString {
append("https://") append("https://")
append(domain) append(domain)
@@ -67,8 +66,8 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
} }
} }
} }
val root = context.httpGet(url).parseHtml().body().getElementById("content") val root = webClient.httpGet(url).parseHtml().body().requireElementById("content")
?.selectLast("div.index-container") ?: parseFailed("Root not found") .selectLastOrThrow("div.index-container")
val regexBrackets = Regex("\\[[^]]+]|\\([^)]+\\)") val regexBrackets = Regex("\\[[^]]+]|\\([^)]+\\)")
val regexSpaces = Regex("\\s+") val regexSpaces = Regex("\\s+")
return root.select(".gallery").map { div -> return root.select(".gallery").map { div ->
@@ -100,8 +99,8 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val root = context.httpGet( val root = webClient.httpGet(
url = manga.url.toAbsoluteUrl(getDomain()) url = manga.url.toAbsoluteUrl(domain),
).parseHtml().body().requireElementById("bigcontainer") ).parseHtml().body().requireElementById("bigcontainer")
val img = root.requireElementById("cover").selectFirstOrThrow("img") val img = root.requireElementById("cover").selectFirstOrThrow("img")
val tagContainers = root.requireElementById("tags").select(".tag-container") val tagContainers = root.requireElementById("tags").select(".tag-container")
@@ -126,18 +125,18 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
uploadDate = dateFormat.tryParse( uploadDate = dateFormat.tryParse(
tagContainers.find { x -> x.ownText() == "Uploaded:" } tagContainers.find { x -> x.ownText() == "Uploaded:" }
?.selectFirst("time") ?.selectFirst("time")
?.attr("datetime") ?.attr("datetime"),
), ),
branch = null, branch = null,
source = source, source = source,
) ),
) ),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val url = chapter.url.toAbsoluteUrl(getDomain()) val url = chapter.url.toAbsoluteUrl(domain)
val root = context.httpGet(url).parseHtml().requireElementById("thumbnail-container") val root = webClient.httpGet(url).parseHtml().requireElementById("thumbnail-container")
return root.select(".thumb-container").map { div -> return root.select(".thumb-container").map { div ->
val a = div.selectFirstOrThrow("a") val a = div.selectFirstOrThrow("a")
val img = div.selectFirstOrThrow("img") val img = div.selectFirstOrThrow("img")
@@ -145,7 +144,6 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
MangaPage( MangaPage(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
referer = url,
preview = img.attrAsAbsoluteUrlOrNull("data-src") preview = img.attrAsAbsoluteUrlOrNull("data-src")
?: img.attrAsAbsoluteUrl("src"), ?: img.attrAsAbsoluteUrl("src"),
source = source, source = source,
@@ -154,7 +152,7 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val root = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml().body() val root = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml().body()
.requireElementById("image-container") .requireElementById("image-container")
return root.selectFirstOrThrow("img").attrAsAbsoluteUrl("src") return root.selectFirstOrThrow("img").attrAsAbsoluteUrl("src")
} }
@@ -169,7 +167,7 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
} }
private suspend fun getTags(page: Int): Set<MangaTag> { private suspend fun getTags(page: Int): Set<MangaTag> {
val root = context.httpGet("https://${getDomain()}/tags/popular?page=$page").parseHtml().body() val root = webClient.httpGet("https://${domain}/tags/popular?page=$page").parseHtml().body()
.getElementById("tag-container") .getElementById("tag-container")
return root?.parseTags().orEmpty() return root?.parseTags().orEmpty()
} }
@@ -186,4 +184,4 @@ class NHentaiParser(override val context: MangaLoaderContext) : MangaParser(Mang
private fun buildQuery(tags: Collection<MangaTag>) = tags.joinToString(separator = " ") { tag -> private fun buildQuery(tags: Collection<MangaTag>) = tags.joinToString(separator = " ") { tag ->
"tag:\"${tag.key}\"" "tag:\"${tag.key}\""
} }
} }

View File

@@ -0,0 +1,226 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("NETTRUYEN", "NetTruyen", "vi")
class NetTruyenParser(context: MangaLoaderContext) :
PagedMangaParser(context, MangaSource.NETTRUYEN, pageSize = 36) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain(
"www.nettruyento.com",
arrayOf("www.nettruyento.com", "nettruyento.com", "nettruyenin.com"),
)
override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RATING)
private val mutex = Mutex()
private val dateFormat = SimpleDateFormat("dd/MM/yy", Locale.US)
private var tagCache: ArrayMap<String, MangaTag>? = null
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val rating = doc.selectFirst("span[itemprop=ratingValue]")
?.ownText()
?.toFloatOrNull() ?: 0f
val chapterElements = doc.getElementById("nt_listchapter")?.select("ul > li") ?: doc.parseFailed()
val chapters = chapterElements.mapChapters(reversed = true) { index, element ->
val a = element.selectFirst("div.chapter > a") ?: return@mapChapters null
val relativeUrl = a.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null
val timeText = element.selectFirst("div.col-xs-4.text-center.no-wrap.small")?.text()
MangaChapter(
id = generateUid(relativeUrl),
name = a.text(),
number = index + 1,
url = relativeUrl,
scanlator = null,
uploadDate = parseChapterTime(timeText),
branch = null,
source = source,
)
}
return manga.copy(
rating = rating / 5,
chapters = chapters,
description = doc.selectFirst("div.detail-content > p")?.html(),
isNsfw = doc.selectFirst("div.alert.alert-danger > strong:contains(Cảnh báo độ tuổi)") != null,
)
}
// 20 giây trước
// 52 phút trước
// 6 giờ trước
// 2 ngày trước
// 19:09 30/07
// 23/12/21
private fun parseChapterTime(timeText: String?): Long {
if (timeText.isNullOrEmpty()) {
return 0L
}
val timeWords = arrayOf("giây", "phút", "giờ", "ngày")
val calendar = Calendar.getInstance()
val timeArr = timeText.split(' ')
if (WordSet(*timeWords).anyWordIn(timeText)) {
val timeSuffix = timeArr.getOrNull(1)
val timeDiff = timeArr.getOrNull(0)?.toIntOrNull() ?: return 0L
when (timeSuffix) {
timeWords[0] -> calendar.add(Calendar.SECOND, -timeDiff)
timeWords[1] -> calendar.add(Calendar.MINUTE, -timeDiff)
timeWords[2] -> calendar.add(Calendar.HOUR, -timeDiff)
timeWords[3] -> calendar.add(Calendar.DATE, -timeDiff)
else -> return 0L
}
} else {
val relativeDate = timeArr.lastOrNull() ?: return 0L
val dateString = when (relativeDate.split('/').size) {
2 -> {
val currentYear = calendar.get(Calendar.YEAR).toString().takeLast(2)
"$relativeDate/$currentYear"
}
3 -> relativeDate
else -> return 0L
}
calendar.timeInMillis = dateFormat.tryParse(dateString)
}
return calendar.time.time
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val isSearching = !query.isNullOrEmpty()
val url = buildString {
append("https://")
append(domain)
if (isSearching) {
append("/tim-truyen?keyword=")
append(query!!.urlEncoded())
append("&page=")
append(page)
} else {
val tagQuery = tags.orEmpty().joinToString(",") { it.key }
append("/tim-truyen-nang-cao?genres=$tagQuery")
append("&notgenres=&gender=-1&status=-1&minchapter=1&sort=${getSortOrderKey(sortOrder)}")
append("&page=$page")
}
}
val response = if (isSearching) {
val result = runCatchingCancellable { webClient.httpGet(url) }
val exception = result.exceptionOrNull()
if (exception is NotFoundException) {
return emptyList()
}
result.getOrThrow()
} else {
webClient.httpGet(url)
}
val itemsElements = response.parseHtml()
.select("div.ModuleContent > div.items")
.select("div.item")
return itemsElements.mapNotNull { item ->
val tooltipElement = item.selectFirst("div.box_tootip") ?: return@mapNotNull null
val absUrl = item.selectFirst("div.image > a")?.attrAsAbsoluteUrlOrNull("href") ?: return@mapNotNull null
val slug = absUrl.substringAfterLast('/')
val mangaState = when (tooltipElement.selectFirst("div.message_main > p:contains(Tình trạng)")?.ownText()) {
"Đang tiến hành" -> MangaState.ONGOING
"Hoàn thành" -> MangaState.FINISHED
else -> null
}
val tagMap = getOrCreateTagMap()
val tagsElement = tooltipElement.selectFirst("div.message_main > p:contains(Thể loại)")?.ownText().orEmpty()
val mangaTags = tagsElement.split(',').mapNotNullToSet { tagMap[it.trim()] }
Manga(
id = generateUid(slug),
title = tooltipElement.selectFirst("div.title")?.text().orEmpty(),
altTitle = null,
url = absUrl.toRelativeUrl(domain),
publicUrl = absUrl,
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = item.selectFirst("div.image a img")?.absUrl("data-original").orEmpty(),
largeCoverUrl = null,
tags = mangaTags,
state = mangaState,
author = tooltipElement.selectFirst("div.message_main > p:contains(Tác giả)")?.ownText(),
description = tooltipElement.selectFirst("div.box_text")?.text(),
chapters = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val pageElements = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
.select("div.reading-detail.box_doc > div img")
return pageElements.map { element ->
val url = element.attrAsAbsoluteUrl("data-original")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val map = getOrCreateTagMap()
val tagSet = ArraySet<MangaTag>(map.size)
for (entry in map) {
tagSet.add(entry.value)
}
return tagSet
}
private suspend fun getOrCreateTagMap(): ArrayMap<String, MangaTag> = mutex.withLock {
tagCache?.let { return@withLock it }
val doc = webClient.httpGet("/tim-truyen-nang-cao".toAbsoluteUrl(domain)).parseHtml()
val tagItems = doc.select("div.genre-item")
val result = ArrayMap<String, MangaTag>(tagItems.size)
for (item in tagItems) {
val title = item.text().trim()
val key = item.select("span[data-id]").attr("data-id")
result[title] = MangaTag(title = title, key = key, source = source)
}
tagCache = result
result
}
private fun getSortOrderKey(sortOrder: SortOrder) = when (sortOrder) {
SortOrder.UPDATED -> 0
SortOrder.POPULARITY -> 10
SortOrder.NEWEST -> 15
SortOrder.RATING -> 20
else -> throw IllegalArgumentException("Sort order ${sortOrder.name} not supported")
}
}

View File

@@ -11,8 +11,8 @@ private const val STATUS_ONGOING = "連載"
private const val STATUS_FINISHED = "完結" private const val STATUS_FINISHED = "完結"
@MangaSourceParser("NICOVIDEO_SEIGA", "Nicovideo Seiga", "ja") @MangaSourceParser("NICOVIDEO_SEIGA", "Nicovideo Seiga", "ja")
class NicovideoSeigaParser(override val context: MangaLoaderContext) : class NicovideoSeigaParser(context: MangaLoaderContext) :
MangaParser(MangaSource.NICOVIDEO_SEIGA), MangaParser(context, MangaSource.NICOVIDEO_SEIGA),
MangaParserAuthProvider { MangaParserAuthProvider {
override val authUrl: String override val authUrl: String
@@ -24,7 +24,7 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val body = context.httpGet("https://${getDomain("app")}/my/apps").parseHtml().body() val body = webClient.httpGet("https://${getDomain("app")}/my/apps").parseHtml().body()
return body.selectFirst("#userinfo > div > div > strong")?.text() ?: throw AuthRequiredException(source) return body.selectFirst("#userinfo > div > div > strong")?.text() ?: throw AuthRequiredException(source)
} }
@@ -47,15 +47,18 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
val url = when { val url = when {
!query.isNullOrEmpty() -> return if (offset == 0) getSearchList(query, page) else emptyList() !query.isNullOrEmpty() -> return if (offset == 0) getSearchList(query, page) else emptyList()
tags.isNullOrEmpty() -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}" tags.isNullOrEmpty() -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
tags.size == 1 -> "https://$domain/manga/list?category=${tags.first().key}&page=$page&sort=${getSortKey(sortOrder)}" tags.size == 1 -> "https://$domain/manga/list?category=${tags.first().key}&page=$page" +
"&sort=${getSortKey(sortOrder)}"
tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 category") tags.size > 1 -> throw IllegalArgumentException("This source supports only 1 category")
else -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}" else -> "https://$domain/manga/list?page=$page&sort=${getSortKey(sortOrder)}"
} }
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val comicList = doc.body().select("#comic_list > ul > li") ?: parseFailed("Container not found") val comicList = doc.body().select("#comic_list > ul > li") ?: doc.parseFailed("Container not found")
val items = comicList.select("div > .description > div > div") val items = comicList.select("div > .description > div > div")
return items.mapNotNull { item -> return items.mapNotNull { item ->
val href = item.selectFirst(".comic_icon > div > a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null val href =
item.selectFirst(".comic_icon > div > a")?.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null
val statusText = item.selectFirst(".mg_description_header > .mg_icon > .content_status > span")?.text() val statusText = item.selectFirst(".mg_description_header > .mg_icon > .content_status > span")?.text()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
@@ -85,22 +88,24 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain("seiga"))).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(getDomain("seiga"))).parseHtml()
val contents = doc.body().selectFirst("#contents") ?: parseFailed("Cannot find root") val contents = doc.body().selectFirstOrThrow("#contents")
val statusText = contents val statusText = contents
.select("div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span") .select("div.mg_work_detail > div > div:nth-child(2) > div.tip.content_status.status_series > span")
.text() .text()
return manga.copy( return manga.copy(
description = contents.selectFirst("div.mg_work_detail > div > div.row > div.description_text")?.html(), description = contents.selectFirst("div.mg_work_detail > div > div.row > div.description_text")?.html(),
largeCoverUrl = contents.selectFirst("div.primaries > div.main_visual > a > img")?.attrAsAbsoluteUrlOrNull("src"), largeCoverUrl = contents.selectFirst("div.primaries > div.main_visual > a > img")
?.attrAsAbsoluteUrlOrNull("src"),
state = when (statusText) { state = when (statusText) {
STATUS_ONGOING -> MangaState.ONGOING STATUS_ONGOING -> MangaState.ONGOING
STATUS_FINISHED -> MangaState.FINISHED STATUS_FINISHED -> MangaState.FINISHED
else -> null else -> null
}, },
isNsfw = contents.select(".icon_adult").isNotEmpty(), isNsfw = contents.select(".icon_adult").isNotEmpty(),
chapters = contents.select("#episode_list > ul > li").mapIndexedNotNull { i, li -> chapters = contents.select("#episode_list > ul > li").mapChapters { i, li ->
val href = li.selectFirst("div > div.description > div.title > a")?.attrAsRelativeUrl("href") ?: parseFailed() val href = li.selectFirst("div > div.description > div.title > a")
?.attrAsRelativeUrl("href") ?: li.parseFailed()
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = li.select("div > div.description > div.title > a").text(), name = li.select("div > div.description > div.title > a").text(),
@@ -117,7 +122,7 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain("seiga")) val fullUrl = chapter.url.toAbsoluteUrl(getDomain("seiga"))
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
if (!doc.select("#login_manga").isEmpty()) if (!doc.select("#login_manga").isEmpty())
throw AuthRequiredException(source) throw AuthRequiredException(source)
val root = doc.body().select("#page_contents > li") val root = doc.body().select("#page_contents > li")
@@ -127,17 +132,16 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain("seiga")}/manga/list").parseHtml() val doc = webClient.httpGet("https://${getDomain("seiga")}/manga/list").parseHtml()
val root = doc.body().select("#mg_category_list > ul > li") ?: parseFailed("Cannot find tags") val root = doc.body().selectOrThrow("#mg_category_list > ul > li")
return root.mapToSet { li -> return root.mapToSet { li ->
val a = li.selectFirst("a") ?: parseFailed("a is null") val a = li.selectFirstOrThrow("a")
MangaTag( MangaTag(
title = a.text(), title = a.text(),
key = a.attrAsRelativeUrlOrNull("href").orEmpty(), key = a.attrAsRelativeUrlOrNull("href").orEmpty(),
@@ -148,10 +152,11 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
private suspend fun getSearchList(query: String, page: Int): List<Manga> { private suspend fun getSearchList(query: String, page: Int): List<Manga> {
val domain = getDomain("seiga") val domain = getDomain("seiga")
val doc = context.httpGet("https://$domain/manga/search/?q=$query&page=$page&sort=score").parseHtml() val doc = webClient.httpGet("https://$domain/manga/search/?q=$query&page=$page&sort=score").parseHtml()
val root = doc.body().select(".search_result__item") val root = doc.body().select(".search_result__item")
return root.mapNotNull { item -> return root.mapNotNull { item ->
val href = item.selectFirst(".search_result__item__thumbnail > a")?.attrAsRelativeUrl("href") ?: parseFailed() val href = item.selectFirst(".search_result__item__thumbnail > a")
?.attrAsRelativeUrl("href") ?: doc.parseFailed()
Manga( Manga(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
@@ -176,4 +181,4 @@ class NicovideoSeigaParser(override val context: MangaLoaderContext) :
SortOrder.UPDATED -> "manga_updated" SortOrder.UPDATED -> "manga_updated"
else -> "manga_view" else -> "manga_view"
} }
} }

View File

@@ -2,54 +2,68 @@ package org.koitharu.kotatsu.parsers.site
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
private const val PAGE_SIZE = 26
internal abstract class NineMangaParser( internal abstract class NineMangaParser(
final override val context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaSource, source: MangaSource,
defaultDomain: String, defaultDomain: String,
) : MangaParser(source) { ) : PagedMangaParser(context, source, pageSize = 26), Interceptor {
override val configKeyDomain = ConfigKey.Domain(defaultDomain, null) override val configKeyDomain = ConfigKey.Domain(defaultDomain, null)
init { init {
context.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes") context.cookieJar.insertCookies(domain, "ninemanga_template_desk=yes")
} }
private val headers = Headers.Builder() override val headers = Headers.Builder()
.add("Accept-Language", "en-US;q=0.7,en;q=0.3") .add("Accept-Language", "en-US;q=0.7,en;q=0.3")
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
.build() .build()
override val sortOrders: Set<SortOrder> = Collections.singleton( override val sortOrders: Set<SortOrder> = Collections.singleton(
SortOrder.POPULARITY, SortOrder.POPULARITY,
) )
override suspend fun getList( override fun intercept(chain: Interceptor.Chain): Response {
offset: Int, val request = chain.request()
.newBuilder()
.removeHeader("Referer")
.build()
val response = chain.proceed(request)
val responseUrl = response.request.url
if (responseUrl.host != request.url.host) {
throw IOException("Unexpected redirect to ${responseUrl.host}")
}
return response
}
override suspend fun getListPage(
page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
val url = buildString { val url = buildString {
append("https://") append("https://")
append(getDomain()) append(domain)
when { when {
!query.isNullOrEmpty() -> { !query.isNullOrEmpty() -> {
append("/search/?name_sel=&wd=") append("/search/?name_sel=&wd=")
append(query.urlEncoded()) append(query.urlEncoded())
append("&page=") append("&page=")
} }
!tags.isNullOrEmpty() -> { !tags.isNullOrEmpty() -> {
append("/search/?category_id=") append("/search/?category_id=")
for (tag in tags) { for (tag in tags) {
@@ -58,6 +72,7 @@ internal abstract class NineMangaParser(
} }
append("&page=") append("&page=")
} }
else -> { else -> {
append("/category/index_") append("/category/index_")
} }
@@ -65,13 +80,13 @@ internal abstract class NineMangaParser(
append(page) append(page)
append(".html") append(".html")
} }
val doc = context.httpGet(url, headers).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.direlist") val root = doc.body().selectFirst("ul.direlist")
?: throw ParseException("Cannot find root") ?: doc.parseFailed("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host val baseHost = root.baseUri().toHttpUrl().host
return root.select("li").map { node -> return root.select("li").map { node ->
val href = node.selectFirst("a")?.absUrl("href") val href = node.selectFirst("a")?.absUrl("href")
?: parseFailed("Link not found") ?: node.parseFailed("Link not found")
val relUrl = href.toRelativeUrl(baseHost) val relUrl = href.toRelativeUrl(baseHost)
val dd = node.selectFirst("dd") val dd = node.selectFirst("dd")
Manga( Manga(
@@ -93,14 +108,11 @@ internal abstract class NineMangaParser(
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet( val doc = webClient.httpGet(
manga.url.toAbsoluteUrl(getDomain()) + "?waring=1", manga.url.toAbsoluteUrl(domain) + "?waring=1",
headers,
).parseHtml() ).parseHtml()
val root = doc.body().selectFirst("div.manga") val root = doc.body().selectFirstOrThrow("div.manga")
?: throw ParseException("Cannot find root") val infoRoot = root.selectFirstOrThrow("div.bookintro")
val infoRoot = root.selectFirst("div.bookintro")
?: throw ParseException("Cannot find info")
return manga.copy( return manga.copy(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first() tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a -> ?.select("a")?.mapToSet { a ->
@@ -115,10 +127,10 @@ internal abstract class NineMangaParser(
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first() description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
?.html()?.substringAfter("</b>"), ?.html()?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li") chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
?.asReversed()?.mapIndexed { i, li -> ?.mapChapters(reversed = true) { i, li ->
val a = li.selectFirst("a.chapter_list_a") val a = li.selectFirst("a.chapter_list_a")
val href = a?.attrAsRelativeUrlOrNull("href") val href = a?.attrAsRelativeUrlOrNull("href")
?.replace("%20", " ") ?: parseFailed("Link not found") ?.replace("%20", " ") ?: li.parseFailed("Link not found")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text(), name = a.text(),
@@ -134,28 +146,27 @@ internal abstract class NineMangaParser(
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain()), headers).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
return doc.body().getElementById("page")?.select("option")?.map { option -> return doc.body().getElementById("page")?.select("option")?.map { option ->
val url = option.attr("value") val url = option.attr("value")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = chapter.url.toAbsoluteUrl(getDomain()),
preview = null, preview = null,
source = source, source = source,
) )
} ?: throw ParseException("Pages list not found at ${chapter.url}") } ?: doc.parseFailed("Pages list not found")
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain()), headers).parseHtml() val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body() val root = doc.body()
return root.selectFirst("a.pic_download")?.absUrl("href") return root.selectFirst("a.pic_download")?.absUrl("href")
?: throw ParseException("Page image not found") ?: doc.parseFailed("Page image not found")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/search/?type=high", headers) val doc = webClient.httpGet("https://${domain}/search/?type=high")
.parseHtml() .parseHtml()
val root = doc.body().getElementById("search_form") val root = doc.body().getElementById("search_form")
return root?.select("li.cate_list")?.mapNotNullToSet { li -> return root?.select("li.cate_list")?.mapNotNullToSet { li ->
@@ -166,7 +177,7 @@ internal abstract class NineMangaParser(
key = cateId, key = cateId,
source = source, source = source,
) )
} ?: parseFailed("Root not found") } ?: doc.parseFailed("Root not found")
} }
private fun parseStatus(status: String) = when { private fun parseStatus(status: String) = when {
@@ -261,4 +272,4 @@ internal abstract class NineMangaParser(
MangaSource.NINEMANGA_FR, MangaSource.NINEMANGA_FR,
"fr.ninemanga.com", "fr.ninemanga.com",
) )
} }

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -16,16 +15,19 @@ private const val MAX_THUMB_INDEX = 19
@MangaSourceParser("NUDEMOON", "Nude-Moon", "ru") @MangaSourceParser("NUDEMOON", "Nude-Moon", "ru")
internal class NudeMoonParser( internal class NudeMoonParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
) : MangaParser(MangaSource.NUDEMOON), MangaParserAuthProvider { ) : MangaParser(context, MangaSource.NUDEMOON), MangaParserAuthProvider {
override val configKeyDomain = ConfigKey.Domain("nude-moon.net", null) override val configKeyDomain = ConfigKey.Domain(
defaultValue = "nude-moon.org",
presetValues = arrayOf("nude-moon.org", "nude-moon.net"),
)
override val authUrl: String override val authUrl: String
get() = "https://${getDomain()}/index.php" get() = "https://${domain}/index.php"
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
return context.cookieJar.getCookies(getDomain()).any { return context.cookieJar.getCookies(domain).any {
it.name == "fusion_user" it.name == "fusion_user"
} }
} }
@@ -38,7 +40,7 @@ internal class NudeMoonParser(
init { init {
context.cookieJar.insertCookies( context.cookieJar.insertCookies(
getDomain(), domain,
"NMfYa=1;", "NMfYa=1;",
"nm_mobile=0;", "nm_mobile=0;",
) )
@@ -50,7 +52,7 @@ internal class NudeMoonParser(
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = domain
val url = when { val url = when {
!query.isNullOrEmpty() -> "https://$domain/search?stext=${query.urlEncoded()}&rowstart=$offset" !query.isNullOrEmpty() -> "https://$domain/search?stext=${query.urlEncoded()}&rowstart=$offset"
!tags.isNullOrEmpty() -> tags.joinToString( !tags.isNullOrEmpty() -> tags.joinToString(
@@ -59,12 +61,13 @@ internal class NudeMoonParser(
postfix = "&rowstart=$offset", postfix = "&rowstart=$offset",
transform = { it.key.urlEncoded() }, transform = { it.key.urlEncoded() },
) )
else -> "https://$domain/all_manga?${getSortKey(sortOrder)}&rowstart=$offset" else -> "https://$domain/all_manga?${getSortKey(sortOrder)}&rowstart=$offset"
} }
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().run { val root = doc.body().run {
selectFirst("td.main-bg") ?: selectFirst("td.main-body") selectFirst("td.main-bg") ?: selectFirst("td.main-body")
} ?: parseFailed("Cannot find root") } ?: doc.parseFailed("Cannot find root")
return root.select("table.news_pic2").mapNotNull { row -> return root.select("table.news_pic2").mapNotNull { row ->
val a = row.selectFirst("td.bg_style1")?.selectFirst("a") val a = row.selectFirst("td.bg_style1")?.selectFirst("a")
?: return@mapNotNull null ?: return@mapNotNull null
@@ -100,9 +103,9 @@ internal class NudeMoonParser(
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val body = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml().body() val body = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
val root = body.selectFirst("table.shoutbox") val root = body.selectFirst("table.shoutbox")
?: parseFailed("Cannot find root") ?: body.parseFailed("Cannot find root")
val info = root.select("div.tbl2") val info = root.select("div.tbl2")
val lastInfo = info.last() val lastInfo = info.last()
return manga.copy( return manga.copy(
@@ -136,14 +139,14 @@ internal class NudeMoonParser(
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val mangaId = chapter.url.substringAfterLast('/').substringBefore('-').toIntOrNull() val mangaId = chapter.url.substringAfterLast('/').substringBefore('-').toIntOrNull()
val script = doc.select("script").firstNotNullOfOrNull { val script = doc.select("script").firstNotNullOfOrNull {
it.html().takeIf { x -> x.contains(" images = new ") } it.html().takeIf { x -> x.contains(" images = new ") }
} ?: if (isAuthorized) { } ?: if (isAuthorized) {
parseFailed("Cannot find pages list") doc.parseFailed("Cannot find pages list")
} else { } else {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
@@ -154,7 +157,6 @@ internal class NudeMoonParser(
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
referer = fullUrl,
preview = if (i <= MAX_THUMB_INDEX && mangaId != null) { preview = if (i <= MAX_THUMB_INDEX && mangaId != null) {
val part2 = url.substringBeforeLast('/') val part2 = url.substringBeforeLast('/')
val part3 = url.substringAfterLast('/') val part3 = url.substringAfterLast('/')
@@ -169,12 +171,12 @@ internal class NudeMoonParser(
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain() val domain = domain
val doc = context.httpGet("https://$domain/all_manga").parseHtml() val doc = webClient.httpGet("https://$domain/all_manga").parseHtml()
val root = doc.body().getElementsContainingOwnText("Поиск манги по тегам") val root = doc.body().getElementsContainingOwnText("Поиск манги по тегам")
.firstOrNull()?.parents()?.find { it.tag().normalName() == "tbody" } .firstOrNull()?.parents()?.find { it.tag().normalName() == "tbody" }
?.selectFirst("td.textbox")?.selectFirst("td.small") ?.selectFirst("td.textbox")?.selectFirst("td.small")
?: parseFailed("Tags root not found") ?: doc.parseFailed("Tags root not found")
return root.select("a").mapToSet { return root.select("a").mapToSet {
MangaTag( MangaTag(
title = it.text().toTitleCase(), title = it.text().toTitleCase(),
@@ -186,7 +188,7 @@ internal class NudeMoonParser(
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val body = context.httpGet("https://${getDomain()}/").parseHtml() val body = webClient.httpGet("https://${domain}/").parseHtml()
.body() .body()
return body return body
.getElementsContainingOwnText("Профиль") .getElementsContainingOwnText("Профиль")
@@ -197,15 +199,11 @@ internal class NudeMoonParser(
throw if (body.selectFirst("form[name=\"loginform\"]") != null) { throw if (body.selectFirst("form[name=\"loginform\"]") != null) {
AuthRequiredException(source) AuthRequiredException(source)
} else { } else {
ParseException("Cannot find username") body.parseFailed("Cannot find username")
} }
} }
} }
override fun getFaviconUrl(): String {
return "https://${getDomain()}/favicon.jpg"
}
private fun getSortKey(sortOrder: SortOrder) = private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) { when (sortOrder) {
SortOrder.POPULARITY -> "views" SortOrder.POPULARITY -> "views"
@@ -225,4 +223,4 @@ internal class NudeMoonParser(
val suffix = url.substringAfter('-').trimStart('-') val suffix = url.substringAfter('-').trimStart('-')
return "$prefix-online-$suffix" return "$prefix-online-$suffix"
} }
} }

View File

@@ -1,24 +1,19 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Response
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.*
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import java.net.HttpURLConnection
import java.net.URLDecoder import java.net.URLDecoder
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -30,12 +25,19 @@ private const val STATUS_FINISHED = 0
@MangaSourceParser("REMANGA", "Remanga", "ru") @MangaSourceParser("REMANGA", "Remanga", "ru")
internal class RemangaParser( internal class RemangaParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
) : MangaParser(MangaSource.REMANGA), MangaParserAuthProvider { ) : PagedMangaParser(context, MangaSource.REMANGA, PAGE_SIZE), MangaParserAuthProvider {
override val configKeyDomain = ConfigKey.Domain("remanga.org", null) private val baseHeaders = Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Android 13; Mobile; rv:68.0) Gecko/68.0 Firefox/109.0")
.build()
override val headers
get() = getApiHeaders()
override val configKeyDomain = ConfigKey.Domain("remanga.org", arrayOf("remanga.org", "реманга.орг"))
override val authUrl: String override val authUrl: String
get() = "https://${getDomain()}/user/login" get() = "https://${domain}/user/login"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
@@ -46,21 +48,21 @@ internal class RemangaParser(
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
return context.cookieJar.getCookies(getDomain()).any { return context.cookieJar.getCookies(domain).any {
it.name == "user" it.name == "user"
} }
} }
private val regexLastUrlPath = Regex("/[^/]+/?$") private val regexLastUrlPath = Regex("/[^/]+/?$")
override suspend fun getList( override suspend fun getListPage(
offset: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
copyCookies() copyCookies()
val domain = getDomain() val domain = domain
val urlBuilder = StringBuilder() val urlBuilder = StringBuilder()
.append("https://api.") .append("https://api.")
.append(domain) .append(domain)
@@ -77,10 +79,10 @@ internal class RemangaParser(
} }
urlBuilder urlBuilder
.append("&page=") .append("&page=")
.append((offset / PAGE_SIZE) + 1) .append(page)
.append("&count=") .append("&count=")
.append(PAGE_SIZE) .append(PAGE_SIZE)
val content = context.httpGet(urlBuilder.toString(), getApiHeaders()).parseJson() val content = webClient.httpGet(urlBuilder.toString()).parseJson()
.getJSONArray("content") .getJSONArray("content")
return content.mapJSON { jo -> return content.mapJSON { jo ->
val url = "/manga/${jo.getString("dir")}" val url = "/manga/${jo.getString("dir")}"
@@ -111,20 +113,19 @@ internal class RemangaParser(
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
copyCookies() copyCookies()
val domain = getDomain() val domain = domain
val slug = manga.url.find(regexLastUrlPath) val slug = manga.url.find(regexLastUrlPath)
?: throw ParseException("Cannot obtain slug from ${manga.url}") ?: throw ParseException("Cannot obtain slug from ${manga.url}", manga.publicUrl)
val data = context.httpGet( val data = webClient.httpGet(
url = "https://api.$domain/api/titles/$slug/", url = "https://api.$domain/api/titles$slug/",
headers = getApiHeaders(), ).parseJson()
).handle401().parseJson()
val content = try { val content = try {
data.getJSONObject("content") data.getJSONObject("content")
} catch (e: JSONException) { } catch (e: JSONException) {
throw ParseException(data.optString("msg"), e) throw ParseException(data.optString("msg"), manga.publicUrl, e)
} }
val branchId = content.getJSONArray("branches").optJSONObject(0) val branchId = content.getJSONArray("branches").optJSONObject(0)
?.getLong("id") ?: throw ParseException("No branches found") ?.getLong("id") ?: throw ParseException("No branches found", manga.publicUrl)
val chapters = grabChapters(domain, branchId) val chapters = grabChapters(domain, branchId)
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
return manga.copy( return manga.copy(
@@ -141,14 +142,20 @@ internal class RemangaParser(
source = MangaSource.REMANGA, source = MangaSource.REMANGA,
) )
}, },
chapters = chapters.mapIndexed { i, jo -> chapters = chapters.mapChapters { i, jo ->
if (
jo.getBooleanOrDefault("is_paid", false) &&
!jo.getBooleanOrDefault("is_bought", false)
) {
return@mapChapters null
}
val id = jo.getLong("id") val id = jo.getLong("id")
val name = jo.getString("name").toTitleCase(Locale.ROOT) val name = jo.getString("name").toTitleCase(Locale.ROOT)
val publishers = jo.optJSONArray("publishers") val publishers = jo.optJSONArray("publishers")
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
url = "/api/titles/chapters/$id/", url = "/api/titles/chapters/$id/",
number = chapters.size - i, number = jo.getIntOrDefault("index", chapters.size - i),
name = buildString { name = buildString {
append("Том ") append("Том ")
append(jo.optString("tome", "0")) append(jo.optString("tome", "0"))
@@ -170,9 +177,7 @@ internal class RemangaParser(
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val referer = "https://${getDomain()}/" val content = webClient.httpGet(chapter.url.toAbsoluteUrl(getDomain("api")))
val content = context.httpGet(chapter.url.toAbsoluteUrl(getDomain("api")), getApiHeaders())
.handle401()
.parseJson() .parseJson()
.getJSONObject("content") .getJSONObject("content")
val pages = content.optJSONArray("pages") val pages = content.optJSONArray("pages")
@@ -182,25 +187,25 @@ internal class RemangaParser(
} }
if (pubDate != null && pubDate > System.currentTimeMillis()) { if (pubDate != null && pubDate > System.currentTimeMillis()) {
val at = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(Date(pubDate)) val at = SimpleDateFormat.getDateInstance(DateFormat.LONG).format(Date(pubDate))
parseFailed("Глава станет доступной $at") throw ContentUnavailableException("Глава станет доступной $at")
} else { } else {
parseFailed("Глава недоступна") throw ContentUnavailableException("Глава недоступна")
} }
} }
val result = ArrayList<MangaPage>(pages.length()) val result = ArrayList<MangaPage>(pages.length())
for (i in 0 until pages.length()) { for (i in 0 until pages.length()) {
when (val item = pages.get(i)) { when (val item = pages.get(i)) {
is JSONObject -> result += parsePage(item, referer) is JSONObject -> result += parsePage(item)
is JSONArray -> item.mapJSONTo(result) { parsePage(it, referer) } is JSONArray -> item.mapJSONTo(result) { parsePage(it) }
else -> throw ParseException("Unknown json item $item") else -> throw ParseException("Unknown json item $item", chapter.url)
} }
} }
return result return result
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain() val domain = domain
val content = context.httpGet("https://api.$domain/api/forms/titles/?get=genres", getApiHeaders()) val content = webClient.httpGet("https://api.$domain/api/forms/titles/?get=genres")
.parseJson().getJSONObject("content").getJSONArray("genres") .parseJson().getJSONObject("content").getJSONArray("genres")
return content.mapJSONToSet { jo -> return content.mapJSONToSet { jo ->
MangaTag( MangaTag(
@@ -212,24 +217,23 @@ internal class RemangaParser(
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val jo = context.httpGet( val jo = webClient.httpGet(
url = "https://api.${getDomain()}/api/users/current/", url = "https://api.${domain}/api/users/current/",
headers = getApiHeaders(), ).parseJson()
).handle401().parseJson()
return jo.getJSONObject("content").getString("username") return jo.getJSONObject("content").getString("username")
} }
private fun getApiHeaders(): Headers? { private fun getApiHeaders(): Headers {
val userCookie = context.cookieJar.getCookies(getDomain()).find { val userCookie = context.cookieJar.getCookies(domain).find {
it.name == "user" it.name == "user"
} ?: return null } ?: return baseHeaders
val jo = JSONObject(URLDecoder.decode(userCookie.value, Charsets.UTF_8.name())) val jo = JSONObject(URLDecoder.decode(userCookie.value, Charsets.UTF_8.name()))
val accessToken = jo.getStringOrNull("access_token") ?: return null val accessToken = jo.getStringOrNull("access_token") ?: return baseHeaders
return Headers.headersOf("authorization", "bearer $accessToken") return baseHeaders.newBuilder().add("authorization", "bearer $accessToken").build()
} }
private fun copyCookies() { private fun copyCookies() {
val domain = getDomain() val domain = domain
context.cookieJar.copyCookies(domain, "api.$domain") context.cookieJar.copyCookies(domain, "api.$domain")
} }
@@ -241,11 +245,10 @@ internal class RemangaParser(
else -> "-chapter_date" else -> "-chapter_date"
} }
private fun parsePage(jo: JSONObject, referer: String) = MangaPage( private fun parsePage(jo: JSONObject) = MangaPage(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
url = jo.getString("link"), url = jo.getString("link"),
preview = null, preview = null,
referer = referer,
source = source, source = source,
) )
@@ -253,10 +256,9 @@ internal class RemangaParser(
val result = ArrayList<JSONObject>(100) val result = ArrayList<JSONObject>(100)
var page = 1 var page = 1
while (true) { while (true) {
val content = context.httpGet( val content = webClient.httpGet(
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100", url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId&page=$page&count=100",
headers = getApiHeaders(), ).parseJson().getJSONArray("content")
).handle401().parseJson().getJSONArray("content")
val len = content.length() val len = content.length()
if (len == 0) { if (len == 0) {
break break
@@ -269,10 +271,4 @@ internal class RemangaParser(
} }
return result return result
} }
}
private fun Response.handle401() = apply {
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw AuthRequiredException(source)
}
}
}

View File

@@ -0,0 +1,153 @@
package org.koitharu.kotatsu.parsers.site
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("TRUYENTRANHLH", "TruyentranhLH", "vi")
class TruyentranhLHParser(context: MangaLoaderContext) :
PagedMangaParser(context, source = MangaSource.TRUYENTRANHLH, pageSize = 18) {
override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain("truyentranhlh.net", null)
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
private val mutex = Mutex()
private var tagCache: Map<String, MangaTag>? = null
override suspend fun getDetails(manga: Manga): Manga {
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val infoHeaderEl = docs.selectFirst("main.section-body")
val infoEl = docs.selectFirst("main.section-body .series-information")
val tags = infoEl?.select(".info-item:contains(Thể loại) > .info-value > a")?.mapNotNullToSet {
getOrCreateTagMap()[it.text().trim()]
}
val state = when (infoEl?.selectFirst(".info-item:contains(Tình trạng) > .info-value")?.text()) {
"Đang tiến hành" -> MangaState.ONGOING
"Đã hoàn thành" -> MangaState.FINISHED
else -> null
}
val rating = infoHeaderEl?.let {
val like = it.selectFirst("#like .block.feature-name")?.text()?.toIntOrNull()
val disLike = it.selectFirst("#dislike .block.feature-name")?.text()?.toIntOrNull()
when {
like == null || disLike == null -> RATING_UNKNOWN
like == 0 && disLike == 0 -> RATING_UNKNOWN
else -> like.toFloat() / (like + disLike)
}
}
val chapterDateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.US)
return manga.copy(
altTitle = infoEl?.selectFirst(".info-item:contains(Tên khác) > .info-value")?.text(),
author = infoEl?.select(".info-item:contains(Tác giả) > .info-value")?.joinToString { it.text() },
tags = tags ?: emptySet(),
state = state,
rating = rating ?: RATING_UNKNOWN,
description = infoHeaderEl?.selectFirst(".series-summary .summary-content")?.html(),
chapters = docs.select("ul.list-chapters.at-series > a").mapChapters(reversed = true) { index, element ->
MangaChapter(
id = generateUid(element.attrAsRelativeUrl("href")),
name = element.selectFirst(".chapter-name")?.text()?.trim().orEmpty(),
number = index + 1,
url = element.attrAsRelativeUrl("href"),
scanlator = null,
uploadDate = chapterDateFormat.tryParse(element.selectFirst(".chapter-time")?.text()),
branch = null,
source = source,
)
},
)
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val sortQuery = when (sortOrder) {
SortOrder.UPDATED -> "update"
SortOrder.NEWEST -> "new"
SortOrder.RATING -> "like"
SortOrder.POPULARITY -> "top"
SortOrder.ALPHABETICAL -> "az"
}
val url = urlBuilder().apply {
addPathSegment("tim-kiem")
addQueryParameter("sort", sortQuery)
addQueryParameter("page", page.toString())
if (!query.isNullOrEmpty()) {
addQueryParameter("q", query)
}
if (!tags.isNullOrEmpty()) {
val tagsQuery = tags.joinToString(separator = ",") { it.key }
addEncodedQueryParameter("accept_genres", tagsQuery)
}
}.build()
return webClient.httpGet(url).parseHtml()
.select(".container .card.card-dark .row > .thumb-item-flow")
.mapNotNull {
val a = it.selectFirstOrThrow(".thumb-wrapper a")
Manga(
id = generateUid(a.attrAsRelativeUrl("href")),
url = a.attrAsRelativeUrl("href"),
publicUrl = a.attrAsAbsoluteUrl("href"),
title = it.select(".thumb_attr.series-title").text(),
altTitle = null,
rating = RATING_UNKNOWN,
isNsfw = false,
coverUrl = a.selectFirst("div[data-bg]")?.attrAsAbsoluteUrl("data-bg").orEmpty(),
tags = emptySet(),
state = null,
author = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val url = chapter.url.toAbsoluteUrl(domain)
return webClient.httpGet(url).parseHtml().select("#chapter-content > img").mapNotNull {
val imageUrl = it.attrAsRelativeUrlOrNull("data-src")
?: it.attrAsRelativeUrlOrNull("src")
?: return@mapNotNull null
MangaPage(
id = generateUid(imageUrl),
url = imageUrl,
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
return ArraySet(getOrCreateTagMap().values)
}
private suspend fun getOrCreateTagMap(): Map<String, MangaTag> = mutex.withLock {
tagCache?.let { return it }
val docs = webClient.httpGet("/tim-kiem".toAbsoluteUrl(domain)).parseHtml()
val tags = docs.select(".search-border-left .row > .search-gerne_item").mapNotNull {
MangaTag(
title = it.text().trim(),
key = it.selectFirst("label[data-genre-id]")
?.attr("data-genre-id")
?.trim() ?: return@mapNotNull null,
source = source,
)
}
val tagMap = tags.associateByTo(ArrayMap(tags.size)) { it.title }
tagCache = tagMap
return tagMap
}
}

View File

@@ -0,0 +1,176 @@
package org.koitharu.kotatsu.parsers.site
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("UNION_MANGAS", "Union Mangás", "pt")
class UnionMangasParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaSource.UNION_MANGAS, 40) {
override val sortOrders = EnumSet.of(
SortOrder.ALPHABETICAL,
SortOrder.POPULARITY,
)
override val configKeyDomain = ConfigKey.Domain("unionleitor.top", emptyArray())
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (page == searchPaginator.firstPage) {
search(query)
} else {
emptyList()
}
}
val tag = tags.oneOrThrowIfMany()
val url = urlBuilder()
.addPathSegment("lista-mangas")
.addPathSegment(
when {
tag != null -> tag.key
sortOrder == SortOrder.ALPHABETICAL -> "a-z"
else -> "visualizacoes"
},
).addPathSegment(page.toString())
val doc = webClient.httpGet(url.build()).parseHtml()
val root = doc.selectFirstOrThrow("div.tamanho-bloco-perfil")
return root.select(".lista-mangas-novos").map { div ->
val a = div.selectFirstOrThrow("a")
val img = div.selectFirstOrThrow("img")
val href = a.attrAsRelativeUrl("href")
Manga(
id = generateUid(href),
url = href,
publicUrl = a.attrAsAbsoluteUrl("href"),
title = div.selectLastOrThrow("a").text(),
coverUrl = img.attrAsAbsoluteUrl("src"),
altTitle = null,
rating = RATING_UNKNOWN,
tags = emptySet(),
description = div.selectLast("div")?.ownText(),
state = null,
author = null,
isNsfw = false,
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.selectFirstOrThrow(".perfil-manga")
val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ROOT)
return manga.copy(
rating = root.select("h2")
.find { it.ownText().startsWith('#') }
?.ownText()?.drop(1)?.toFloatOrNull()?.div(10f) ?: manga.rating,
largeCoverUrl = root.selectFirst("img.img-thumbnail")?.attrAsAbsoluteUrlOrNull("src"),
description = root.selectFirst(".panel-default")?.selectFirst(".panel-body")?.html(),
author = root.tableValue("Autor")?.ownText(),
altTitle = root.tableValue("Título(s) Alternativo(s)")?.ownText(),
state = when (root.tableValue("Status")?.selectLast(".label")?.text()) {
"Completo" -> MangaState.FINISHED
"Ativo" -> MangaState.ONGOING
else -> null
},
tags = root.tableValue("Gênero(s)")?.select("a")?.mapToSet {
it.toMangaTag()
} ?: manga.tags,
isNsfw = root.selectFirst(".alert-danger")?.html()?.contains("18 anos") == true,
chapters = root.select("div.row.capitulos").mapChapters(reversed = true) { i, div ->
val a = div.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href")
val title = a.text()
MangaChapter(
id = generateUid(href),
name = title,
number = i + 1,
url = href,
scanlator = div.selectLast("a")?.text()?.takeUnless { it == title },
uploadDate = dateFormat.tryParse(
a.nextElementSibling()?.text()?.removeSurrounding("(", ")"),
),
branch = null,
source = source,
)
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirstOrThrow("article")
return root.selectOrThrow("img[pag]").mapNotNull { img ->
val href = img.attrAsRelativeUrl("src")
if (href.startsWith("/images/banner")) {
return@mapNotNull null
}
MangaPage(
id = generateUid(href),
url = href,
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet(urlBuilder().addPathSegment("lista-mangas").build()).parseHtml()
val ul = doc.body().selectFirstOrThrow(".nav-tabs").selectFirstOrThrow("ul.dropdown-menu")
return ul.select("li").mapToSet { li ->
li.selectFirstOrThrow("a").toMangaTag()
}
}
private suspend fun search(query: String): List<Manga> {
val domain = domain
val json = webClient.httpGet(
urlBuilder()
.addPathSegments("assets/busca.php")
.addQueryParameter("nomeManga", query)
.build(),
).parseJson()
return json.getJSONArray("items").mapJSON { jo ->
val href = "/pagina-manga/" + jo.getString("url")
Manga(
id = generateUid(href),
url = href,
publicUrl = href.toAbsoluteUrl(domain),
title = jo.getString("titulo"),
rating = RATING_UNKNOWN,
tags = emptySet(),
author = jo.getStringOrNull("autor"),
coverUrl = jo.getString("imagem"),
state = null,
isNsfw = false,
altTitle = null,
source = source,
)
}
}
private fun Element.tableValue(title: String): Element? {
return select("h4.media-heading")
.find { it.selectFirst("label.subtit-manga")?.text()?.contains(title, ignoreCase = true) == true }
}
private fun Element.toMangaTag() = MangaTag(
title = text().toTitleCase(sourceLocale),
key = attr("href").removeSuffix('/').substringAfterLast('/'),
source = source,
)
}

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.parsers.site.grouple
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.parseFailed
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.parsers.util.urlEncoded
@MangaSourceParser("ALLHENTAI", "ALlHentai", "ru")
internal class AllHentaiParser(
context: MangaLoaderContext,
) : GroupleParser(context, MangaSource.ALLHENTAI, 1) {
override val configKeyDomain = ConfigKey.Domain(
"2023.allhen.online",
null,
)
override val defaultIsNsfw = true
override val authUrl: String
get() {
val targetUri = "https://${domain}/".urlEncoded()
return "https://qawa.org/internal/auth/login?targetUri=$targetUri&siteId=1"
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
try {
return super.getPages(chapter)
} catch (e: ParseException) {
if (isAuthorized) {
throw e
} else {
throw AuthRequiredException(source)
}
}
}
override suspend fun getUsername(): String {
val root = webClient.httpGet("https://qawa.org/").parseHtml().body()
val element = root.selectFirst("img.user-avatar") ?: throw AuthRequiredException(source)
val res = element.parent()?.text()
return if (res.isNullOrEmpty()) {
root.parseFailed("Cannot find username")
} else res
}
}

View File

@@ -1,10 +1,21 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.grouple
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.headersContentLength
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
@@ -15,11 +26,25 @@ private const val PAGE_SIZE = 70
private const val PAGE_SIZE_SEARCH = 50 private const val PAGE_SIZE_SEARCH = 50
private const val NSFW_ALERT = "сексуальные сцены" private const val NSFW_ALERT = "сексуальные сцены"
private const val NOTHING_FOUND = "Ничего не найдено" private const val NOTHING_FOUND = "Ничего не найдено"
private const val MIN_IMAGE_SIZE = 1024L
private const val HEADER_ACCEPT = "Accept"
internal abstract class GroupleParser(source: MangaSource, userAgent: String) : MangaParser(source) { internal abstract class GroupleParser(
context: MangaLoaderContext,
source: MangaSource,
private val siteId: Int,
) : MangaParser(context, source), MangaParserAuthProvider, Interceptor {
private val headers = Headers.Builder() @Volatile
.add("User-Agent", userAgent) private var cachedPagesServer: String? = null
protected open val defaultIsNsfw = false
private val userAgentKey = ConfigKey.UserAgent(
"Mozilla/5.0 (X11; U; UNICOS lcLinux; en-US) Gecko/20140730 (KHTML, like Gecko, Safari/419.3) Arora/0.8.0",
)
override val headers: Headers = Headers.Builder()
.add("User-Agent", config[userAgentKey])
.build() .build()
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
@@ -29,45 +54,54 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
SortOrder.RATING, SortOrder.RATING,
) )
override val authUrl: String
get() {
val targetUri = "https://${domain}/".urlEncoded()
return "https://grouple.co/internal/auth/sso?siteId=$siteId&=targetUri=$targetUri"
}
override val isAuthorized: Boolean
get() = context.cookieJar.getCookies(domain).any { it.name == "gwt" }
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = domain
val doc = when { val doc = when {
!query.isNullOrEmpty() -> context.httpPost( !query.isNullOrEmpty() -> webClient.httpPost(
"https://$domain/search", "https://$domain/search",
mapOf( mapOf(
"q" to query.urlEncoded(), "q" to query.urlEncoded(),
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString(), "offset" to (offset upBy PAGE_SIZE_SEARCH).toString(),
), ),
headers,
) )
tags.isNullOrEmpty() -> context.httpGet(
tags.isNullOrEmpty() -> webClient.httpGet(
"https://$domain/list?sortType=${ "https://$domain/list?sortType=${
getSortKey(sortOrder) getSortKey(sortOrder)
}&offset=${offset upBy PAGE_SIZE}", }&offset=${offset upBy PAGE_SIZE}",
headers,
) )
tags.size == 1 -> context.httpGet(
tags.size == 1 -> webClient.httpGet(
"https://$domain/list/genre/${tags.first().key}?sortType=${ "https://$domain/list/genre/${tags.first().key}?sortType=${
getSortKey(sortOrder) getSortKey(sortOrder)
}&offset=${offset upBy PAGE_SIZE}", }&offset=${offset upBy PAGE_SIZE}",
headers,
) )
offset > 0 -> return emptyList() offset > 0 -> return emptyList()
else -> advancedSearch(domain, tags) else -> advancedSearch(domain, tags)
}.parseHtml().body() }.parseHtml().body()
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults")) val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
?: parseFailed("Cannot find root") ?: doc.parseFailed("Cannot find root")
val tiles = root.selectFirst("div.tiles.row") ?: if ( val tiles = root.selectFirst("div.tiles.row") ?: if (
root.select(".alert").any { it.ownText() == NOTHING_FOUND } root.select(".alert").any { it.ownText() == NOTHING_FOUND }
) { ) {
return emptyList() return emptyList()
} else { } else {
parseFailed("No tiles found") doc.parseFailed("No tiles found")
} }
val baseHost = root.baseUri().toHttpUrl().host val baseHost = root.baseUri().toHttpUrl().host
return tiles.select("div.tile").mapNotNull { node -> return tiles.select("div.tile").mapNotNull { node ->
@@ -92,14 +126,13 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
altTitle = descDiv.selectFirst("h4")?.text(), altTitle = descDiv.selectFirst("h4")?.text(),
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original")?.replace("_p.", ".").orEmpty(), coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original")?.replace("_p.", ".").orEmpty(),
rating = runCatching { rating = runCatching {
node.selectFirst("div.rating") node.selectFirst(".compact-rate")
?.attr("title") ?.attr("title")
?.substringBefore(' ')
?.toFloatOrNull() ?.toFloatOrNull()
?.div(10f) ?.div(5f)
}.getOrNull() ?: RATING_UNKNOWN, }.getOrNull() ?: RATING_UNKNOWN,
author = tileInfo?.selectFirst("a.person-link")?.text(), author = tileInfo?.selectFirst("a.person-link")?.text(),
isNsfw = false, isNsfw = defaultIsNsfw,
tags = runCatching { tags = runCatching {
tileInfo?.select("a.element-link") tileInfo?.select("a.element-link")
?.mapToSet { ?.mapToSet {
@@ -113,6 +146,7 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
state = when { state = when {
node.selectFirst("div.tags") node.selectFirst("div.tags")
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED ?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
else -> null else -> null
}, },
source = source, source = source,
@@ -121,9 +155,9 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain()), headers).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).checkAuthRequired().parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent") val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: parseFailed("Cannot find root") ?: doc.parseFailed("Cannot find root")
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US) val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img") val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
return manga.copy( return manga.copy(
@@ -139,10 +173,11 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
source = source, source = source,
) )
}, },
isNsfw = root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) }, author = root.selectFirst("a.person-link")?.text() ?: manga.author,
isNsfw = manga.isNsfw || root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) },
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table") chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr -> ?.select("tr:has(td > a)")?.mapChapters(reversed = true) { i, tr ->
val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null val a = tr.selectFirst("a.chapter-link") ?: return@mapChapters null
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
var translators = "" var translators = ""
val translatorElement = a.attr("title") val translatorElement = a.attr("title")
@@ -156,7 +191,7 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(), name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
number = i + 1, number = i + 1,
url = href, url = href,
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()), uploadDate = dateFormat.tryParse(tr.selectFirst("td.date")?.text()),
scanlator = translators, scanlator = translators,
source = source, source = source,
branch = null, branch = null,
@@ -166,11 +201,13 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain()) + "?mtr=1", headers).parseHtml() val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain) + "?mtr=1")
.checkAuthRequired()
.parseHtml()
val scripts = doc.select("script") val scripts = doc.select("script")
for (script in scripts) { for (script in scripts) {
val data = script.html() val data = script.html()
val pos = data.indexOf("rm_h.initReader(") val pos = data.indexOf("rm_h.readerInit( 0,")
if (pos == -1) { if (pos == -1) {
continue continue
} }
@@ -183,7 +220,7 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
} }
val ja = JSONArray("[$json]") val ja = JSONArray("[$json]")
val pages = ja.getJSONArray(1) val pages = ja.getJSONArray(1)
val servers = ja.getJSONArray(4).mapJSON { it.getString("path") } val servers = ja.getJSONArray(3).mapJSON { it.getString("path") }
val serversStr = servers.joinToString("|") val serversStr = servers.joinToString("|")
return (0 until pages.length()).map { i -> return (0 until pages.length()).map { i ->
val page = pages.getJSONArray(i) val page = pages.getJSONArray(i)
@@ -193,33 +230,57 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
id = generateUid(url), id = generateUid(url),
url = "$primaryServer|$serversStr|$url", url = "$primaryServer|$serversStr|$url",
preview = null, preview = null,
referer = chapter.url,
source = source, source = source,
) )
} }
} }
parseFailed("Pages list not found at ${chapter.url}") doc.parseFailed("Pages list not found at ${chapter.url}")
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val parts = page.url.split('|') val parts = page.url.split('|')
if (parts.size < 2) {
throw ParseException("No servers found for page", page.url)
}
val path = parts.last() val path = parts.last()
val servers = parts.dropLast(1).toSet() // fast path
val headers = Headers.headersOf("Referer", page.referer) cachedPagesServer?.let { host ->
for (server in servers) { val url = concatUrl("https://$host/", path)
val url = server + path if (tryHead(url)) {
if (tryHead(url, headers)) {
return url return url
} else {
cachedPagesServer = null
} }
} }
val fallbackServer = servers.firstOrNull() ?: parseFailed("Cannot find any page url") // slow path
return fallbackServer + path val candidates = HashSet<String>((parts.size - 1) * 2)
for (i in 0 until parts.size - 1) {
val server = parts[i].trim().ifEmpty { "https://$domain/" }
candidates.add(concatUrl(server, path))
candidates.add(concatUrl(server, path.substringBeforeLast('?')))
}
return try {
channelFlow {
for (url in candidates) {
launch {
if (tryHead(url)) {
send(url)
}
}
}
}.first().also {
cachedPagesServer = it.toHttpUrlOrNull()?.host
}
} catch (e: NoSuchElementException) {
assert(false) { e.toString() }
candidates.random()
}
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/list/genres/sort_name", headers).parseHtml() val doc = webClient.httpGet("https://${domain}/list/genres/sort_name").parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent") val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?.selectFirst("table.table") ?: parseFailed("Cannot find root") ?.selectFirst("table.table") ?: doc.parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a -> return root.select("a.element-link").mapToSet { a ->
MangaTag( MangaTag(
title = a.text().toTitleCase(), title = a.text().toTitleCase(),
@@ -229,6 +290,37 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
} }
} }
override suspend fun getUsername(): String {
val root = webClient.httpGet("https://grouple.co/").parseHtml().body()
val element = root.selectFirst("img.user-avatar") ?: throw AuthRequiredException(source)
val res = element.parent()?.text()
return if (res.isNullOrEmpty()) {
root.parseFailed("Cannot find username")
} else res
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.header(HEADER_ACCEPT).isNullOrEmpty()) {
return chain.proceed(request)
}
val ext = request.url.pathSegments.lastOrNull()?.substringAfterLast('.', "")?.lowercase(Locale.ROOT)
return if (ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "webp") {
chain.proceed(
request.newBuilder()
.header(HEADER_ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.build(),
)
} else {
chain.proceed(request)
}
}
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
}
private fun getSortKey(sortOrder: SortOrder) = private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) { when (sortOrder) {
SortOrder.ALPHABETICAL -> "name" SortOrder.ALPHABETICAL -> "name"
@@ -241,24 +333,24 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response { private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
val url = "https://$domain/search/advanced" val url = "https://$domain/search/advanced"
// Step 1: map catalog genres names to advanced-search genres ids // Step 1: map catalog genres names to advanced-search genres ids
val tagsIndex = context.httpGet(url, headers).parseHtml() val tagsIndex = webClient.httpGet(url).parseHtml()
.body().selectFirst("form.search-form") .body().selectFirst("form.search-form")
?.select("div.form-group") ?.select("div.form-group")
?.get(1) ?: parseFailed("Genres filter element not found") ?.get(1) ?: throw ParseException("Genres filter element not found", url)
val tagNames = tags.map { it.title.lowercase() } val tagNames = tags.map { it.title.lowercase() }
val payload = HashMap<String, String>() val payload = HashMap<String, String>()
var foundGenres = 0 var foundGenres = 0
tagsIndex.select("li.property").forEach { li -> tagsIndex.select("li.property").forEach { li ->
val name = li.text().trim().lowercase() val name = li.text().trim().lowercase()
val id = li.selectFirst("input")?.id() val id = li.selectFirst("input")?.id()
?: parseFailed("Id for tag $name not found") ?: li.parseFailed("Id for tag $name not found")
payload[id] = if (name in tagNames) { payload[id] = if (name in tagNames) {
foundGenres++ foundGenres++
"in" "in"
} else "" } else ""
} }
if (foundGenres != tags.size) { if (foundGenres != tags.size) {
parseFailed("Some genres are not found") tagsIndex.parseFailed("Some genres are not found")
} }
// Step 2: advanced search // Step 2: advanced search
payload["q"] = "" payload["q"] = ""
@@ -272,10 +364,19 @@ internal abstract class GroupleParser(source: MangaSource, userAgent: String) :
payload["s_sale"] = "" payload["s_sale"] = ""
payload["years"] = "1900,2099" payload["years"] = "1900,2099"
payload["+"] = "Искать".urlEncoded() payload["+"] = "Искать".urlEncoded()
return context.httpPost(url, payload, headers) return webClient.httpPost(url, payload)
} }
private suspend fun tryHead(url: String, headers: Headers): Boolean = runCatching { private suspend fun tryHead(url: String): Boolean = runCatchingCancellable {
context.httpHead(url, headers).isSuccessful val response = webClient.httpHead(url)
response.isSuccessful && response.headersContentLength() >= MIN_IMAGE_SIZE
}.getOrDefault(false) }.getOrDefault(false)
}
private fun Response.checkAuthRequired(): Response {
val lastPathSegment = request.url.pathSegments.lastOrNull() ?: return this
if (lastPathSegment == "login") {
throw AuthRequiredException(source)
}
return this
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.grouple
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
@@ -7,11 +7,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
@MangaSourceParser("MINTMANGA", "MintManga", "ru") @MangaSourceParser("MINTMANGA", "MintManga", "ru")
internal class MintMangaParser( internal class MintMangaParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
) : GroupleParser(MangaSource.MINTMANGA, "mintmangafun") { ) : GroupleParser(context, MangaSource.MINTMANGA, 2) {
override val configKeyDomain = ConfigKey.Domain( override val configKeyDomain = ConfigKey.Domain(
"mintmanga.live", "mintmanga.live",
arrayOf("mintmanga.live", "mintmanga.com"), arrayOf("mintmanga.live", "mintmanga.com"),
) )
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.grouple
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
@@ -7,11 +7,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
@MangaSourceParser("READMANGA_RU", "ReadManga", "ru") @MangaSourceParser("READMANGA_RU", "ReadManga", "ru")
internal class ReadmangaParser( internal class ReadmangaParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
) : GroupleParser(MangaSource.READMANGA_RU, "readmangafun") { ) : GroupleParser(context, MangaSource.READMANGA_RU, 1) {
override val configKeyDomain = ConfigKey.Domain( override val configKeyDomain = ConfigKey.Domain(
"readmanga.io", "readmanga.live",
arrayOf("readmanga.io", "readmanga.live", "readmanga.me"), arrayOf("readmanga.io", "readmanga.live", "readmanga.me"),
) )
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.grouple
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
@@ -7,8 +7,9 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
@MangaSourceParser("SELFMANGA", "SelfManga", "ru") @MangaSourceParser("SELFMANGA", "SelfManga", "ru")
internal class SelfMangaParser( internal class SelfMangaParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
) : GroupleParser(MangaSource.SELFMANGA, "selfmangafun") { ) : GroupleParser(context, MangaSource.SELFMANGA, 3) {
override val configKeyDomain = ConfigKey.Domain("selfmanga.live", null) override val configKeyDomain = ConfigKey.Domain("selfmanga.live", null)
}
}

View File

@@ -0,0 +1,69 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("HENTAI_4FREE", "Hentai4Free", "en")
internal class Hentai4Free(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.HENTAI_4FREE, "hentai4free.net") {
override val tagPrefix = "hentai-tag/"
override val isNsfwSource = true
override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain/").parseHtml()
val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val list = root1?.select("li").orEmpty()
val keySet = HashSet<String>(list.size)
return list.mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast(tagPrefix, "")
if (href.isEmpty() || !keySet.add(href)) {
return@mapNotNullToSet null
}
MangaTag(
key = href,
title = a.ownText().trim().toTitleCase(),
source = source,
)
}
}
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
val slug = manga.url.removeSuffix('/').substringAfterLast('/')
val doc2 = webClient.httpPost(
"https://$domain/hentai/$slug/ajax/chapters/",
mapOf(),
).parseHtml()
val ul = doc2.body().selectFirstOrThrow("ul")
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
return ul.select("li").mapChapters(reversed = true) { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
}
}
}

View File

@@ -0,0 +1,81 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("ISEKAISCAN_EU", "IsekaiScan", "en")
internal class IsekaiScanEuParser(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.ISEKAISCAN_EU, "isekaiscan.to") {
override val datePattern = "MM/dd/yyyy"
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
doc.selectFirst("ul.version-chap")?.let {
return parseChapters(it)
}
val mangaId = doc.body().requireElementById("manga-chapters-holder").attr("data-id")
val ul = webClient.httpPost(
"https://${domain}/wp-admin/admin-ajax.php",
mapOf(
"action" to "manga_get_chapters",
"manga" to mangaId,
),
).parseHtml().body().selectFirstOrThrow("ul")
return parseChapters(ul)
}
override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://$domain/mangax/").parseHtml()
val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
if (root1 == null && root2 == null) {
doc.parseFailed("Root not found")
}
val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty()
val keySet = HashSet<String>(list.size)
return list.mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast(tagPrefix, "")
if (href.isEmpty() || !keySet.add(href)) {
return@mapNotNullToSet null
}
MangaTag(
key = href,
title = a.ownText().toTitleCase(Locale.ENGLISH),
source = source,
)
}
}
private fun parseChapters(ul: Element): List<MangaChapter> {
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
return ul.select("li").mapChapters(reversed = true) { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
}
}
}

View File

@@ -0,0 +1,183 @@
package org.koitharu.kotatsu.parsers.site.madara
import androidx.collection.arraySetOf
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
abstract class Madara5Parser @InternalParsersApi constructor(
context: MangaLoaderContext,
source: MangaSource,
domain: String,
) : PagedMangaParser(context, source, pageSize = 22) {
protected open val datePattern = "MMMM dd, HH:mm"
protected open val tagPrefix = "/mangas/"
protected open val nsfwTags = arraySetOf("yaoi", "yuri", "mature")
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.UPDATED)
override val configKeyDomain = ConfigKey.Domain(domain, null)
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val domain = domain
val url = buildString {
append("https://")
append(domain)
append("/search?s=")
if (!query.isNullOrEmpty()) {
append(query.urlEncoded())
}
append("&post_type=wp-manga")
if (!tags.isNullOrEmpty()) {
for (tag in tags) {
append("&genre%5B%5D=")
append(tag.key)
}
}
append("&op=1&author=&artist=&page=")
append(page)
}
val root = webClient.httpGet(url).parseHtml().body().selectFirstOrThrow(".search-wrap")
return root.select(".c-tabs-item__content").map { div ->
val a = div.selectFirstOrThrow("a")
val img = div.selectLastOrThrow("img")
val href = a.attrAsRelativeUrl("href")
val postContent = root.selectFirstOrThrow(".post-content")
val tagSet = postContent.getElementsContainingOwnText("Genre")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { it.asMangaTag() }.orEmpty()
Manga(
id = generateUid(href),
title = a.attr("title"),
altTitle = postContent.getElementsContainingOwnText("Alternative")
.firstOrNull()?.tableValue()?.text()?.trim(),
url = href,
publicUrl = a.attrAsAbsoluteUrl("href"),
coverUrl = img.src().orEmpty(),
author = postContent.getElementsContainingOwnText("Author")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
isNsfw = isNsfw(tagSet),
rating = div.selectFirstOrThrow(".score").text()
.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN,
tags = tagSet,
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
.selectFirstOrThrow(".site-content")
val postContent = root.selectFirstOrThrow(".post-content")
val tags = postContent.getElementsContainingOwnText("Genre")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
val mangaId = root.getElementById("manga-chapters-holder")?.attr("data-id")?.toLongOrNull()
?: root.parseFailed("Cannot find mangaId")
return manga.copy(
description = (root.selectFirst(".detail-content")
?: root.selectFirstOrThrow(".description-summary")).html(),
author = postContent.getElementsContainingOwnText("Author")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
tags = tags,
isNsfw = isNsfw(tags),
chapters = loadChapters(mangaId),
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
val arrayData = doc.getElementById("arraydata") ?: doc.parseFailed("#arraydata not found")
return arrayData.html().split(',').map { url ->
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("http://${domain}/").parseHtml().body()
return doc.getElementsByAttributeValueContaining("href", tagPrefix)
.mapToSet { it.asMangaTag() }
}
private suspend fun loadChapters(mangaId: Long): List<MangaChapter> {
val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
val doc = webClient.httpGet("https://${domain}/ajax-list-chapter?mangaID=$mangaId").parseHtml()
return doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, li ->
val a = li.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href")
MangaChapter(
id = generateUid(href),
url = href,
name = a.text(),
number = i + 1,
branch = null,
uploadDate = dateFormat.tryParse(
li.selectFirst(".chapter-release-date")?.text()?.trim(),
),
scanlator = null,
source = source,
)
}
}
protected fun isNsfw(tags: Set<MangaTag>): Boolean {
return tags.any { it.key in nsfwTags }
}
private fun Element.src(): String? {
return absUrl("data-src").ifEmpty {
absUrl("src")
}.takeUnless { it.isEmpty() }
}
private fun Element.tableValue(): Element {
for (p in parents()) {
val children = p.children()
if (children.size == 2) {
return children[1]
}
}
parseFailed("Cannot find tableValue for node ${text()}")
}
private fun String.asMangaState() = when (trim().lowercase(sourceLocale)) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
}
private fun Element.asMangaTag() = MangaTag(
title = ownText(),
key = attr("href").removeSuffix('/').substringAfterLast('/')
.replace('-', '+'),
source = source,
)
@MangaSourceParser("MANGAOWLS", "BeautyManga", "en")
class BeautyManga(context: MangaLoaderContext) : Madara5Parser(context, MangaSource.MANGAOWLS, "beautymanga.com")
}

View File

@@ -0,0 +1,69 @@
package org.koitharu.kotatsu.parsers.site.madara
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
internal abstract class Madara6Parser(
context: MangaLoaderContext,
source: MangaSource,
domain: String,
) : MadaraParser(context, source, domain) {
override val datePattern: String = "dd MMMM yyyy"
override suspend fun getDetails(manga: Manga): Manga {
return coroutineScope {
val chapters = async { loadChapters(manga.url) }
val body = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml().body()
parseDetails(manga, body, chapters.await())
}
}
protected fun Element.tableValue(): Element {
for (p in parents()) {
val children = p.children()
if (children.size == 2) {
return children[1]
}
}
parseFailed("Cannot find tableValue for node ${text()}")
}
protected abstract fun String.asMangaState(): MangaState?
protected fun Element.asMangaTag() = MangaTag(
title = ownText(),
key = attr("href").removeSuffix('/').substringAfterLast('/')
.replace('-', '+'),
source = source,
)
protected open suspend fun loadChapters(mangaUrl: String): List<MangaChapter> {
val url = mangaUrl.toAbsoluteUrl(domain).removeSuffix('/') + "/ajax/chapters/"
val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
val doc = webClient.httpPost(url, emptyMap()).parseHtml()
return doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, li ->
val a = li.selectFirstOrThrow("a")
val href = a.attrAsRelativeUrl("href")
MangaChapter(
id = generateUid(href),
url = href,
name = a.text(),
number = i + 1,
branch = null,
uploadDate = dateFormat.tryParse(
li.selectFirst(".chapter-release-date")?.text()?.trim(),
),
scanlator = null,
source = source,
)
}
}
protected abstract fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga
}

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.madara
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
@@ -12,13 +15,11 @@ import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
private const val PAGE_SIZE = 12
internal abstract class MadaraParser( internal abstract class MadaraParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaSource, source: MangaSource,
domain: String, domain: String,
) : MangaParser(source) { ) : PagedMangaParser(context, source, pageSize = 12) {
override val configKeyDomain = ConfigKey.Domain(domain, null) override val configKeyDomain = ConfigKey.Domain(domain, null)
@@ -29,48 +30,52 @@ internal abstract class MadaraParser(
protected open val tagPrefix = "manga-genre/" protected open val tagPrefix = "manga-genre/"
protected open val isNsfwSource = false protected open val isNsfwSource = false
protected open val datePattern = "MMMM dd, yyyy"
override suspend fun getList( init {
offset: Int, paginator.firstPage = 0
searchPaginator.firstPage = 0
}
override suspend fun getListPage(
page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val tag = when { val tag = tags.oneOrThrowIfMany()
tags.isNullOrEmpty() -> null
tags.size == 1 -> tags.first()
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
}
val payload = createRequestTemplate() val payload = createRequestTemplate()
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString() payload["page"] = page.toString()
payload["vars[meta_key]"] = when (sortOrder) { payload["vars[meta_key]"] = when (sortOrder) {
SortOrder.POPULARITY -> "_wp_manga_views" SortOrder.POPULARITY -> "_wp_manga_views"
SortOrder.UPDATED -> "_latest_update" SortOrder.UPDATED -> "_latest_update"
else -> "_wp_manga_views" else -> "_wp_manga_views"
} }
payload["vars[wp-manga-genre]"] = tag?.key.orEmpty() payload["vars[wp-manga-genre]"] = tag?.key.orEmpty()
payload["vars[s]"] = query.orEmpty() payload["vars[s]"] = query?.urlEncoded().orEmpty()
val doc = context.httpPost( val doc = webClient.httpPost(
"https://${getDomain()}/wp-admin/admin-ajax.php", "https://$domain/wp-admin/admin-ajax.php",
payload, payload,
).parseHtml() ).parseHtml()
return doc.select("div.row.c-tabs-item__content").map { div -> return doc.select("div.row.c-tabs-item__content").ifEmpty {
doc.select("div.page-item-detail.manga")
}.map { div ->
val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: parseFailed("Link not found") ?: div.parseFailed("Link not found")
val summary = div.selectFirst(".tab-summary") val summary = div.selectFirst(".tab-summary") ?: div.selectFirst(".item-summary")
Manga( Manga(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.toAbsoluteUrl(div.host ?: getDomain()), publicUrl = href.toAbsoluteUrl(div.host ?: domain),
coverUrl = div.selectFirst("img")?.src().orEmpty(), coverUrl = div.selectFirst("img")?.src().orEmpty(),
title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(), title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(),
altTitle = null, altTitle = null,
rating = div.selectFirst("span.total_votes")?.ownText() rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f, ?.toFloatOrNull()?.div(5f) ?: -1f,
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a -> tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapNotNullToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix('/').substringAfterLast('/'),
title = a.text().toTitleCase(), title = a.text().ifEmpty { return@mapNotNullToSet null }.toTitleCase(),
source = source, source = source,
) )
}.orEmpty(), }.orEmpty(),
@@ -90,12 +95,12 @@ internal abstract class MadaraParser(
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml() val doc = webClient.httpGet("https://$domain/manga/").parseHtml()
val body = doc.body() val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu") val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled") val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
if (root1 == null && root2 == null) { if (root1 == null && root2 == null) {
parseFailed("Root not found") doc.parseFailed("Root not found")
} }
val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty() val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty()
val keySet = HashSet<String>(list.size) val keySet = HashSet<String>(list.size)
@@ -108,24 +113,26 @@ internal abstract class MadaraParser(
} }
MangaTag( MangaTag(
key = href, key = href,
title = a.ownText().trim().toTitleCase(), title = a.ownText().trim().ifEmpty {
a.selectFirst(".menu-image-title")?.text()?.trim() ?: return@mapNotNullToSet null
}.toTitleCase(),
source = source, source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val fullUrl = manga.url.toAbsoluteUrl(getDomain()) val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val chaptersDeferred = async { getChapters(manga, doc) }
val root = doc.body().selectFirst("div.profile-manga") val root = doc.body().selectFirst("div.profile-manga")
?.selectFirst("div.summary_content") ?.selectFirst("div.summary_content")
?.selectFirst("div.post-content") ?.selectFirst("div.post-content")
?: throw ParseException("Root not found") ?: throw ParseException("Root not found", fullUrl)
val root2 = doc.body().selectFirst("div.content-area") val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page") ?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found") ?: throw ParseException("Root2 not found", fullUrl)
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US) manga.copy(
return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a") tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a -> ?.mapNotNullToSet { a ->
MangaTag( MangaTag(
@@ -139,46 +146,52 @@ internal abstract class MadaraParser(
?.select("p") ?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") } ?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() }, ?.joinToString { it.html() },
chapters = root2.select("li").asReversed().mapIndexed { i, li -> chapters = chaptersDeferred.await(),
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
},
) )
} }
protected open suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
val root2 = doc.body().selectFirstOrThrow("div.content-area")
.selectFirstOrThrow("div.c-page")
val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
return root2.select("li").mapChapters(reversed = true) { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.main-col-inner") val root = doc.body().selectFirst("div.main-col-inner")
?.selectFirst("div.reading-content") ?.selectFirst("div.reading-content")
?: throw ParseException("Root not found") ?: throw ParseException("Root not found", fullUrl)
return root.select("div.page-break").map { div -> return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") ?: parseFailed("Page image not found") val img = div.selectFirst("img") ?: div.parseFailed("Page image not found")
val url = img.src()?.toRelativeUrl(getDomain()) ?: parseFailed("Image src not found") val url = img.src()?.toRelativeUrl(domain) ?: div.parseFailed("Image src not found")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }
} }
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { protected fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
date ?: return 0 date ?: return 0
return when { return when {
date.endsWith(" ago", ignoreCase = true) -> { date.endsWith(" ago", ignoreCase = true) -> {
@@ -202,6 +215,7 @@ internal abstract class MadaraParser(
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
}.timeInMillis }.timeInMillis
} }
date.startsWith("today", ignoreCase = true) -> { date.startsWith("today", ignoreCase = true) -> {
Calendar.getInstance().apply { Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0) set(Calendar.HOUR_OF_DAY, 0)
@@ -210,6 +224,7 @@ internal abstract class MadaraParser(
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
}.timeInMillis }.timeInMillis
} }
date.contains(Regex("""\d(st|nd|rd|th)""")) -> { date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it // Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
date.split(" ").map { date.split(" ").map {
@@ -221,6 +236,7 @@ internal abstract class MadaraParser(
} }
.let { dateFormat.tryParse(it.joinToString(" ")) } .let { dateFormat.tryParse(it.joinToString(" ")) }
} }
else -> dateFormat.tryParse(date) else -> dateFormat.tryParse(date)
} }
} }
@@ -240,34 +256,39 @@ internal abstract class MadaraParser(
"dia", "dia",
"day", "day",
).anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis ).anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply { WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply {
add( add(
Calendar.HOUR, Calendar.HOUR,
-number, -number,
) )
}.timeInMillis }.timeInMillis
WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply { WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply {
add( add(
Calendar.MINUTE, Calendar.MINUTE,
-number, -number,
) )
}.timeInMillis }.timeInMillis
WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply { WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply {
add( add(
Calendar.SECOND, Calendar.SECOND,
-number, -number,
) )
}.timeInMillis }.timeInMillis
WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
else -> 0 else -> 0
} }
} }
private fun Element.src(): String? { protected fun Element.src(): String? {
return absUrl("data-src").ifEmpty { var result = absUrl("data-src")
absUrl("src") if (result.isEmpty()) result = absUrl("data-cfsrc")
}.takeUnless { it.isEmpty() } if (result.isEmpty()) result = absUrl("src")
return result.ifEmpty { null }
} }
private fun createRequestTemplate() = private fun createRequestTemplate() =
@@ -283,116 +304,96 @@ internal abstract class MadaraParser(
it.substring(0, pos) to it.substring(pos + 1) it.substring(0, pos) to it.substring(pos + 1)
}.toMutableMap() }.toMutableMap()
@MangaSourceParser("MANGAREAD", "MangaRead", "en") @MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en")
class MangaRead(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") { class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in") {
override val tagPrefix = "genres/" override val datePattern = "dd MMMM HH:mm"
} }
@MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en") @MangaSourceParser("HACHIMANGA", "HachiManga", "ja")
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in") class HachiManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HACHIMANGA, "hachiraw.com") {
@MangaSourceParser("READMANWHA", "ReadManwha", "en") override val sourceLocale: Locale = Locale.ENGLISH
class ReadManwha(context: MangaLoaderContext) : MadaraParser(context, MangaSource.READMANWHA, "readmanwha.net") }
@MangaSourceParser("PIANMANGA", "PianManga", "en")
class PianManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.PIANMANGA, "pianmanga.me")
@MangaSourceParser("MANGAROSIE", "MangaRosie", "en")
class MangaRosie(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROSIE, "mangarosie.in")
@MangaSourceParser("MANGATX", "MangaTx", "en") @MangaSourceParser("MANGATX", "MangaTx", "en")
class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com") class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com")
@MangaSourceParser("MANGAEFFECT", "MangaEffect", "en") @MangaSourceParser("MANGAEFFECT", "MangaEffect", "en")
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com") class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com") {
override val datePattern = "dd.MM.yyyy"
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
class AquaManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.AQUAMANGA, "aquamanga.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/03/cropped-cropped-favicon-1-32x32.png"
}
} }
@MangaSourceParser("MANGATX_OT", "MangaTx (ot)", "en") @MangaSourceParser("AQUAMANGA", "AquaManga", "en")
class MangaTxOt(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX_OT, "manga-tx.com") class AquaManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.AQUAMANGA, "aquamanga.com")
@MangaSourceParser("MANGALEK", "MangaLek", "ar")
class MangaLek(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGALEK, "mangalek.com")
@MangaSourceParser("HARIMANGA", "HariManga", "en")
class HariManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.HARIMANGA, "harimanga.com") {
override val datePattern = "MM/dd/yyyy"
}
@MangaSourceParser("KISSMANGA", "KissManga", "en")
class KissManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.KISSMANGA, "kissmanga.in")
@MangaSourceParser("MANGAROCK", "MangaRock", "en") @MangaSourceParser("MANGAROCK", "MangaRock", "en")
class MangaRock(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROCK, "mangarockteam.com") class MangaRock(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAROCK, "mangarockteam.com")
@MangaSourceParser("ISEKAISCAN_EU", "IsekaiScan (eu)", "en") @MangaSourceParser("FREEMANGA", "FreeManga", "en")
class IsekaiScanEu(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ISEKAISCAN_EU, "isekaiscan.eu") class FreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.FREEMANGA, "freemanga.me")
@MangaSourceParser("ISEKAISCAN", "IsekaiScan", "en")
class IsekaiScan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ISEKAISCAN, "isekaiscan.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/10/isekai-scan-02-01-150x150.png"
}
}
@MangaSourceParser("MANGA_KOMI", "MangaKomi", "en") @MangaSourceParser("MANGA_KOMI", "MangaKomi", "en")
class MangaKomi(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_KOMI, "mangakomi.io") class MangaKomi(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_KOMI, "mangakomi.io")
@MangaSourceParser("MANHWACLAN", "ManhwaClan", "en")
class ManhwaClan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWACLAN, "manhwaclan.com")
@MangaSourceParser("MANGA_3S", "Manga3s", "en") @MangaSourceParser("MANGA_3S", "Manga3s", "en")
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com") class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com") {
override val tagPrefix = "manhwa-genre/"
}
@MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en") @MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en")
class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") { class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") {
override fun getFaviconUrl(): String { override val datePattern: String = "MM/dd"
return "https://${getDomain()}/wp-content/uploads/2021/10/cropped-logo-kool-32x32.jpeg"
}
} }
@MangaSourceParser("TOPMANHUA", "Top Manhua", "en") @MangaSourceParser("TOPMANHUA", "Top Manhua", "en")
class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") { class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") {
override val tagPrefix = "manhua-genre/" override val tagPrefix = "manhua-genre/"
override val datePattern = "MM/dd/yyyy"
} }
@MangaSourceParser("X2MANGA", "X2Manga", "en") @MangaSourceParser("S2MANGA", "S2Manga", "en")
class X2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.X2MANGA, "x2manga.com") class S2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.S2MANGA, "s2manga.com")
@MangaSourceParser("SKY_MANGA", "Sky Manga", "en") @MangaSourceParser("SKY_MANGA", "Sky Manga", "en")
class SkyManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.SKY_MANGA, "skymanga.xyz") { class SkyManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.SKY_MANGA, "skymanga.xyz") {
override val isNsfwSource = true override val isNsfwSource = true
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/cropped-sky-tv-1-32x32.png"
}
} }
@MangaSourceParser("MANGA_DISTRICT", "Manga District", "en") @MangaSourceParser("BAKAMAN", "BakaMan", "th")
class MangaDistrict(context: MangaLoaderContext) : class BakaMan(context: MangaLoaderContext) : MadaraParser(context, MangaSource.BAKAMAN, "bakaman.net") {
MadaraParser(context, MangaSource.MANGA_DISTRICT, "mangadistrict.com") {
override val tagPrefix = "publication-genre/" override val isNsfwSource = false
override val isNsfwSource = true
} }
@MangaSourceParser("HENTAI_4FREE", "Hentai4Free", "en") @MangaSourceParser("HENTAI20", "Hentai20", "en")
class Hentai4Free(context: MangaLoaderContext) : class Hentai20(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.HENTAI_4FREE, "hentai4free.net") { MadaraParser(context, MangaSource.HENTAI20, "hentai20.io") {
override val tagPrefix = "hentai-tag/" override val tagPrefix = "manga-genre/"
override val isNsfwSource = true override val isNsfwSource = true
override suspend fun getTags(): Set<MangaTag> {
val doc = context.httpGet("https://${getDomain()}/").parseHtml()
val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val list = root1?.select("li").orEmpty()
val keySet = HashSet<String>(list.size)
return list.mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast(tagPrefix, "")
if (href.isEmpty() || !keySet.add(href)) {
return@mapNotNullToSet null
}
MangaTag(
key = href,
title = a.ownText().trim().toTitleCase(),
source = source,
)
}
}
} }
@MangaSourceParser("ALLPORN_COMIC", "All Porn Comic", "en") @MangaSourceParser("ALLPORN_COMIC", "All Porn Comic", "en")
@@ -403,36 +404,36 @@ internal abstract class MadaraParser(
override val isNsfwSource = true override val isNsfwSource = true
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/wp-content/uploads/2019/01/cropped-cropped-pcround-32x32.png"
}
} }
@MangaSourceParser("MANHWA_CHILL", "Manhwa Chill", "en") @MangaSourceParser("CAT_300", "Cat300", "th")
class ManhwaChill(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWA_CHILL, "manhwachill.me") class Cat300(context: MangaLoaderContext) : MadaraParser(context, MangaSource.CAT_300, "cat300.com") {
override val isNsfwSource = true
}
@MangaSourceParser("BIBIMANGA", "BibiManga", "en")
class BibiManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.BIBIMANGA, "bibimanga.com") {
override val isNsfwSource = false
}
@MangaSourceParser("TREE_MANGA", "Tree Manga", "en") @MangaSourceParser("TREE_MANGA", "Tree Manga", "en")
class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") { class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") {
override fun getFaviconUrl(): String { override val datePattern = "MM/dd/yyyy"
return "https://${getDomain()}/wp-content/uploads/2017/10/lgoo-treemanga-2-1.jpg"
}
}
@MangaSourceParser("ALLTOPMANGA", "All Top Manga", "en")
class AllTopManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ALLTOPMANGA, "alltopmanga.com") {
override fun getFaviconUrl(): String {
return "https://${getDomain()}/wp-content/uploads/2021/12/cropped-Screenshot_4-removebg-preview-32x32.png"
}
} }
@MangaSourceParser("MANGACV", "Manga Cv", "en") @MangaSourceParser("MANGACV", "Manga Cv", "en")
class MangaCv(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACV, "mangacv.com") { class MangaCv(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACV, "mangacv.com")
override fun getFaviconUrl(): String { @MangaSourceParser("TOONILY", "Toonily", "en")
return "https://${getDomain()}/wp-content/uploads/2020/10/cropped-mangaCV-32x32.png" class Toonily(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOONILY, "toonily.com") {
}
override val tagPrefix = "webtoon-genre/"
override val isNsfwSource = false
} }
@MangaSourceParser("MANGA_MANHUA", "Manga Manhua", "en") @MangaSourceParser("MANGA_MANHUA", "Manga Manhua", "en")
@@ -448,7 +449,9 @@ internal abstract class MadaraParser(
class Manga365(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_365, "365manga.com") class Manga365(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_365, "365manga.com")
@MangaSourceParser("MANGACLASH", "Mangaclash", "en") @MangaSourceParser("MANGACLASH", "Mangaclash", "en")
class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com") class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com") {
override val datePattern = "MM/dd/yyyy"
}
@MangaSourceParser("ZINMANGA", "ZINMANGA", "en") @MangaSourceParser("ZINMANGA", "ZINMANGA", "en")
class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com") class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com")

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("MANGA_DISTRICT", "Manga District", "en")
internal class MangaDistrict(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.MANGA_DISTRICT, "mangadistrict.com") {
override val tagPrefix = "publication-genre/"
override val isNsfwSource = true
override val datePattern = "MMM dd,yyyy"
override suspend fun getChapters(manga: Manga, doc: Document): List<MangaChapter> {
val slug = manga.url.removeSuffix('/').substringAfterLast('/')
val doc2 = webClient.httpPost(
"https://$domain/read-scan/$slug/ajax/chapters/",
mapOf(),
).parseHtml()
val ul = doc2.body().selectFirstOrThrow("ul")
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
return ul.select("li").mapChapters(reversed = true) { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
}
}
}

View File

@@ -0,0 +1,120 @@
package org.koitharu.kotatsu.parsers.site.madara
import androidx.collection.arraySetOf
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("MANGAREAD", "MangaRead", "en")
internal class MangaRead(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") {
override val tagPrefix = "genres/"
override val datePattern = "dd.MM.yyyy"
private val nsfwTags = arraySetOf("yaoi", "yuri", "mature")
init {
paginator.firstPage = 1
searchPaginator.firstPage = 1
}
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
SortOrder.ALPHABETICAL,
SortOrder.RATING,
SortOrder.POPULARITY,
)
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
val url = urlBuilder()
.addPathSegment("page")
.addPathSegment(page.toString())
.addQueryParameter("s", query.orEmpty())
.addQueryParameter("post_type", "wp-manga")
tags?.forEachIndexed { index, tag ->
url.addQueryParameter("genre[$index]", tag.key)
}
url.addQueryParameter("op", "")
.addQueryParameter("author", "")
.addQueryParameter("artist", "")
.addQueryParameter("release", "")
.addQueryParameter("adult", "")
if (query.isNullOrEmpty()) {
url.addQueryParameter(
"&m_orderby=",
when (sortOrder) {
SortOrder.RATING -> "trending"
SortOrder.ALPHABETICAL -> "alphabet"
SortOrder.POPULARITY -> "views"
SortOrder.NEWEST -> "new-manga"
SortOrder.UPDATED -> "latest"
},
)
}
val root = webClient.httpGet(url.build()).parseHtml().body().selectFirstOrThrow(".search-wrap")
return root.select(".c-tabs-item__content").map { div ->
val a = div.selectFirstOrThrow("a")
val img = div.selectLastOrThrow("img")
val href = a.attrAsRelativeUrl("href")
val postContent = root.selectFirstOrThrow(".post-content")
val tagSet = postContent.getElementsContainingOwnText("Genre")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { it.asMangaTag() }.orEmpty()
Manga(
id = generateUid(href),
title = a.attr("title"),
altTitle = postContent.getElementsContainingOwnText("Alternative")
.firstOrNull()?.tableValue()?.text()?.trim(),
url = href,
publicUrl = a.attrAsAbsoluteUrl("href"),
coverUrl = img.src().orEmpty(),
author = postContent.getElementsContainingOwnText("Author")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
isNsfw = isNsfw(tagSet),
rating = div.selectFirstOrThrow(".score").text()
.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN,
tags = tagSet,
source = source,
)
}
}
private fun Element.tableValue(): Element {
for (p in parents()) {
val children = p.children()
if (children.size == 2) {
return children[1]
}
}
parseFailed("Cannot find tableValue for node ${text()}")
}
private fun isNsfw(tags: Set<MangaTag>): Boolean {
return tags.any { it.key in nsfwTags }
}
private fun String.asMangaState() = when (trim().lowercase(sourceLocale)) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
else -> null
}
private fun Element.asMangaTag() = MangaTag(
title = ownText(),
key = attr("href").removeSuffix('/').substringAfterLast('/')
.replace('-', '+'),
source = source,
)
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.parsers.site.madara
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.*
@MangaSourceParser("MANGALINK_AR", "Mangalink", "ar")
internal class MangalinkParser(context: MangaLoaderContext) :
MadaraParser(context, MangaSource.MANGALINK_AR, "mangalink.online") {
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
val chaptersDeferred = async { getChapters(manga, doc) }
val root = doc.body().selectFirst("div.profile-manga")
?.selectFirst("div.summary_content")
?.selectFirst("div.post-content")
?: throw ParseException("Root not found", fullUrl)
val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found", fullUrl)
manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text().toTitleCase(),
source = source,
)
} ?: manga.tags,
description = root2.selectFirst("div.description-summary")
?.selectFirst("div.summary__content")
?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() },
chapters = chaptersDeferred.await(),
)
}
}

View File

@@ -0,0 +1,95 @@
package org.koitharu.kotatsu.parsers.site.madara
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("NEATMANGA", "NeatManga", "en")
internal class NeatManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.NEATMANGA, "neatmangas.com") {
override val datePattern = "dd MMMM yyyy"
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
val chaptersDeferred = async { getChapters(manga) }
val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.profile-manga")
?.selectFirst("div.summary_content")
?.selectFirst("div.post-content")
?: throw ParseException("Root not found", fullUrl)
val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found", fullUrl)
manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text().toTitleCase(),
source = source,
)
} ?: manga.tags,
description = root2.getElementsMatchingOwnText("Summary")
.firstOrNull()
?.nextElementSibling()
?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() },
chapters = chaptersDeferred.await(),
)
}
private suspend fun getChapters(manga: Manga): List<MangaChapter> {
val slug = manga.url.removeSuffix('/').substringAfterLast('/')
val doc2 = webClient.httpPost(
"https://$domain/manga/$slug/ajax/chapters/",
mapOf(),
).parseHtml()
val ul = doc2.body().selectFirstOrThrow("ul")
val dateFormat = SimpleDateFormat(datePattern, Locale.US)
return ul.select("li").mapChapters(reversed = true) { i, li ->
val a = li.selectFirst("a")
val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing")
MangaChapter(
id = generateUid(href),
name = a.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.chapter-release-date i")?.text(),
),
source = source,
scanlator = null,
branch = null,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.main-col-inner")
?.selectFirst("div.reading-content")
?: throw ParseException("Root not found", fullUrl)
return root.select("div.page-break").mapNotNull { div ->
val img = div.selectFirst("img")
if (img == null || img.attr("id").isNullOrEmpty()) {
return@mapNotNull null
}
val url = img.src()?.toRelativeUrl(domain) ?: div.parseFailed("Image src not found")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
}

View File

@@ -0,0 +1,65 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("NEOXSCANS", "NeoxScans", "pt")
internal class NeoxScansParser(context: MangaLoaderContext) :
Madara6Parser(context, MangaSource.NEOXSCANS, "neoxscans.net") {
override val tagPrefix = "manga-genre/"
override val datePattern: String = "dd/MM/yyyy"
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
val root = body.selectFirstOrThrow(".site-content")
val postContent = root.selectFirstOrThrow(".post-content")
val tags = postContent.getElementsContainingOwnText("Genre")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
return manga.copy(
largeCoverUrl = root.selectFirst("picture")
?.selectFirst("img[src]")
?.attrAsAbsoluteUrlOrNull("src"),
description = (root.selectFirst(".post-content")
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
author = postContent.getElementsContainingOwnText("Autor")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
tags = tags,
isNsfw = body.hasClass("adult-content"),
chapters = chapters,
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = webClient.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.main-col-inner")
?.selectFirst("div.reading-content")
?: throw ParseException("Root not found", fullUrl)
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") ?: div.parseFailed("Page image not found")
val url = img.attr("src") ?: div.parseFailed("Image src not found")
MangaPage(
id = generateUid(url),
url = url,
preview = null,
source = source,
)
}
}
override fun String.asMangaState() = when (trim().lowercase(Locale.forLanguageTag("pt"))) {
"Em lançamento" -> MangaState.ONGOING
"Completo" -> MangaState.FINISHED
else -> null
}
}

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
@MangaSourceParser("PRISMA_SCANS", "Prisma Scans", "pt")
internal class PrismaScansParser(context: MangaLoaderContext) :
Madara6Parser(context, MangaSource.PRISMA_SCANS, "prismascans.net") {
override val tagPrefix = "manga-genre/"
override val datePattern = "MMM dd, yyyy"
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
val root = body.selectFirstOrThrow(".site-content")
val postContent = root.selectFirstOrThrow(".post-content")
val tags = postContent.getElementsContainingOwnText("Gênero")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
return manga.copy(
largeCoverUrl = root.selectFirst("picture")
?.selectFirst("img[data-src]")
?.attrAsAbsoluteUrlOrNull("data-src"),
description = root.selectFirstOrThrow(".manga-excerpt").firstElementChild()?.html(),
author = postContent.getElementsContainingOwnText("Artista")
.firstOrNull()?.tableValue()?.text()?.trim(),
altTitle = postContent.getElementsContainingOwnText("Título Alternativo")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
tags = tags,
isNsfw = body.hasClass("adult-content"),
chapters = chapters,
)
}
override fun String.asMangaState() = when (trim().lowercase(sourceLocale)) {
"em lançamento" -> MangaState.ONGOING
"completo",
"cancelado",
-> MangaState.FINISHED
else -> null
}
}

View File

@@ -0,0 +1,86 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.util.*
@MangaSourceParser("REAPER_SCANS_ID", "ReaperScansID", "in")
internal class ReaperScansParser(context: MangaLoaderContext) :
Madara6Parser(context, MangaSource.REAPER_SCANS_ID, "reaperscans.id") {
override val datePattern = "MMMM dd, yyyy"
override val tagPrefix = "genre/"
override val sourceLocale: Locale = Locale.ENGLISH
override fun String.asMangaState(): MangaState? = when (this) {
"OnGoing",
"Upcoming",
-> MangaState.ONGOING
"Completed",
"Dropped",
-> MangaState.FINISHED
else -> null
}
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
val root = body.selectFirstOrThrow(".site-content")
val postContent = root.requireElementById("nav-info")
val tags = postContent.getElementsContainingOwnText("Gênero")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
return manga.copy(
rating = postContent.selectFirstOrThrow(".post-rating")
.selectFirstOrThrow(".total_votes").text().toFloat() / 5f,
largeCoverUrl = root.selectFirst(".summary_image")
?.selectFirst("img[data-src]")
?.attrAsAbsoluteUrlOrNull("data-src")
.assertNotNull("largeCoverUrl"),
description = root.requireElementById("nav-profile")
.selectFirstOrThrow(".description-summary")
.firstElementChild()?.html(),
author = postContent.getElementsContainingOwnText("Author(s)")
.firstOrNull()?.tableValue()?.text()?.trim(),
altTitle = postContent.getElementsContainingOwnText("Alternative")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
tags = tags,
isNsfw = body.hasClass("adult-content"),
chapters = chapters,
)
}
override suspend fun getTags(): Set<MangaTag> {
val doc = webClient.httpGet("https://${domain}/semua-komik/").parseHtml()
val body = doc.body()
val root1 = body.selectFirst("header")?.selectFirst("ul.second-menu")
val root2 = body.selectFirst("div.genres_wrap")?.selectFirst("ul.list-unstyled")
if (root1 == null && root2 == null) {
doc.parseFailed("Root not found")
}
val list = root1?.select("li").orEmpty() + root2?.select("li").orEmpty()
val keySet = HashSet<String>(list.size)
return list.mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast(tagPrefix, "")
if (href.isEmpty() || !keySet.add(href)) {
return@mapNotNullToSet null
}
MangaTag(
key = href,
title = a.ownText().trim().ifEmpty {
a.selectFirst(".menu-image-title")?.text()?.trim() ?: return@mapNotNullToSet null
}.toTitleCase(),
source = source,
)
}
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.parsers.site.madara
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow
import java.util.*
@MangaSourceParser("TATAKAE_SCANS", "Tatakae Scans", "pt")
internal class TatakaeScansParser(context: MangaLoaderContext) :
Madara6Parser(context, MangaSource.TATAKAE_SCANS, "tatakaescan.com") {
override val datePattern: String = "dd 'de' MMMMM 'de' yyyy"
override fun parseDetails(manga: Manga, body: Element, chapters: List<MangaChapter>): Manga {
val root = body.selectFirstOrThrow(".site-content")
val postContent = root.selectFirstOrThrow(".post-content")
val tags = postContent.getElementsContainingOwnText("Gênero")
.firstOrNull()?.tableValue()
?.getElementsByAttributeValueContaining("href", tagPrefix)
?.mapToSet { a -> a.asMangaTag() } ?: manga.tags
return manga.copy(
largeCoverUrl = root.selectFirst("picture")
?.selectFirst("img[data-src]")
?.attrAsAbsoluteUrlOrNull("data-src"),
description = (root.selectFirst(".detail-content")
?: root.selectFirstOrThrow(".manga-excerpt")).html(),
author = postContent.getElementsContainingOwnText("Autor")
.firstOrNull()?.tableValue()?.text()?.trim(),
state = postContent.getElementsContainingOwnText("Status")
.firstOrNull()?.tableValue()?.text()?.asMangaState(),
tags = tags,
isNsfw = body.hasClass("adult-content"),
chapters = chapters,
)
}
override fun String.asMangaState() = when (trim().lowercase(Locale.ROOT)) {
"em lançamento" -> MangaState.ONGOING
else -> null
}
}

View File

@@ -0,0 +1,669 @@
package org.koitharu.kotatsu.parsers.site.mangareader
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.json.JSONObject
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
internal abstract class MangaReaderParser(
context: MangaLoaderContext,
source: MangaSource,
pageSize: Int,
searchPageSize: Int,
) : PagedMangaParser(context, source, pageSize, searchPageSize) {
override val sortOrders: Set<SortOrder>
get() = EnumSet.of(SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.ALPHABETICAL, SortOrder.NEWEST)
protected val idLocale
get() = Locale("in", "ID")
abstract val listUrl: String
abstract val tableMode: Boolean
open val chapterDateFormat = SimpleDateFormat("MMM d, yyyy", idLocale)
private var tagCache: ArrayMap<String, MangaTag>? = null
private val mutex = Mutex()
private var lastSearchPage = 1
override suspend fun getDetails(manga: Manga): Manga {
val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val chapters = docs.select("#chapterlist > ul > li").mapChapters(reversed = true) { index, element ->
val url = element.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapChapters null
MangaChapter(
id = generateUid(url),
name = element.selectFirst(".chapternum")?.text() ?: "Chapter ${index + 1}",
url = url,
number = index + 1,
scanlator = null,
uploadDate = chapterDateFormat.tryParse(element.selectFirst(".chapterdate")?.text()),
branch = null,
source = source,
)
}
return if (tableMode) parseInfoTable(docs, manga, chapters) else parseInfoList(docs, manga, chapters)
}
open suspend fun parseInfoTable(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val mangaInfo = docs.selectFirst("div.seriestucontent > div.seriestucontentr")
val mangaState = mangaInfo?.selectFirst(".infotable td:contains(Status)")?.lastElementSibling()?.let {
when (it.text()) {
"Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
else -> null
}
}
val tagMap = getOrCreateTagMap()
val tags = mangaInfo?.select(".seriestugenre > a")?.mapNotNullToSet { tagMap[it.text()] }
return manga.copy(
description = mangaInfo?.selectFirst("div.entry-content")?.html(),
state = mangaState,
author = mangaInfo?.selectFirst(".infotable td:contains(Author)")?.lastElementSibling()?.text(),
isNsfw = docs.selectFirst(".restrictcontainer") != null,
tags = tags.orEmpty(),
chapters = chapters,
)
}
open suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val mangaState = docs.selectFirst(".info-left .tsinfo div:contains(Status)")?.lastElementChild()?.let {
when (it.text()) {
"Ongoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
else -> null
}
}
val tagMap = getOrCreateTagMap()
val tags = docs.select(".info-right .mgen > a").mapNotNullToSet { tagMap[it.text()] }
return manga.copy(
description = docs.selectFirst(".info-right div.entry-content > p")?.html(),
state = mangaState,
author = docs.selectFirst(".info-left .tsinfo div:contains(Author)")?.lastElementChild()?.text(),
isNsfw = docs.selectFirst(".info-right .alr") != null,
tags = tags,
chapters = chapters,
)
}
override suspend fun getListPage(
page: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
if (!query.isNullOrEmpty()) {
if (page > lastSearchPage) {
return emptyList()
}
val url = buildString {
append("https://")
append(domain)
append("/page/")
append(page)
append("/?s=")
append(query.urlEncoded())
}
val docs = webClient.httpGet(url).parseHtml()
lastSearchPage = docs.selectFirst(".pagination .next")
?.previousElementSibling()
?.text()?.toIntOrNull() ?: 1
return parseMangaList(docs)
}
val sortQuery = when (sortOrder) {
SortOrder.ALPHABETICAL -> "title"
SortOrder.NEWEST -> "latest"
SortOrder.POPULARITY -> "popular"
SortOrder.UPDATED -> "update"
else -> ""
}
val tagKey = "genre[]".urlEncoded()
val tagQuery =
if (tags.isNullOrEmpty()) "" else tags.joinToString(separator = "&", prefix = "&") { "$tagKey=${it.key}" }
val url = buildString {
append("https://")
append(domain)
append(listUrl)
append("/?order=")
append(sortQuery)
append(tagQuery)
append("&page=")
append(page)
}
return parseMangaList(webClient.httpGet(url).parseHtml())
}
private fun parseMangaList(docs: Document): List<Manga> {
return docs.select(".postbody .listupd .bs .bsx").mapNotNull {
val a = it.selectFirst("a") ?: return@mapNotNull null
val relativeUrl = a.attrAsRelativeUrl("href")
val rating = it.selectFirst(".numscore")?.text()
?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN
Manga(
id = generateUid(relativeUrl),
url = relativeUrl,
title = a.attr("title"),
altTitle = null,
publicUrl = a.attrAsAbsoluteUrl("href"),
rating = rating,
isNsfw = false,
coverUrl = it.selectFirst("img.ts-post-image")?.imageUrl().orEmpty(),
tags = emptySet(),
state = null,
author = null,
source = source,
)
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val chapterUrl = chapter.url.toAbsoluteUrl(domain)
val docs = webClient.httpGet(chapterUrl).parseHtml()
val script = docs.selectFirstOrThrow("script:containsData(ts_reader)")
val images = JSONObject(script.data().substringAfter('(').substringBeforeLast(')'))
.getJSONArray("sources")
.getJSONObject(0)
.getJSONArray("images")
val pages = ArrayList<MangaPage>(images.length())
for (i in 0 until images.length()) {
pages.add(
MangaPage(
id = generateUid(images.getString(i)),
url = images.getString(i),
preview = null,
source = source,
),
)
}
return pages
}
override suspend fun getTags(): Set<MangaTag> {
return getOrCreateTagMap().values.toSet()
}
protected suspend fun getOrCreateTagMap(): Map<String, MangaTag> = mutex.withLock {
tagCache?.let { return@withLock it }
val tagMap = ArrayMap<String, MangaTag>()
val url = listUrl.toAbsoluteUrl(domain)
val tagElements = webClient.httpGet(url).parseHtml().select("ul.genrez > li")
for (el in tagElements) {
if (el.text().isEmpty()) continue
tagMap[el.text()] = MangaTag(
title = el.text(),
key = el.selectFirst("input")?.attr("value") ?: continue,
source = source,
)
}
tagCache = tagMap
return@withLock tagMap
}
private fun Element.imageUrl(): String {
return attrAsAbsoluteUrlOrNull("src")
?: attrAsAbsoluteUrlOrNull("data-src")
?: attrAsAbsoluteUrlOrNull("data-cfsrc")
?: ""
}
@MangaSourceParser("MANHWALAND", "Manhwaland", "id")
class ManhwaLandParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.MANHWALAND, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("manhwaland.us", arrayOf("manhwaland.us", "manhwaland.guru"))
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
}
@MangaSourceParser("SEKAIKOMIK", "Sekaikomik", "id")
class SekaikomikParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.SEKAIKOMIK, pageSize = 20, searchPageSize = 100) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("sekaikomik.pro", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM D, yyyy", idLocale)
}
@MangaSourceParser("MANHWAINDO", "Manhwaindo", "id")
class ManhwaIndoParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.MANHWAINDO, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("manhwaindo.id", null)
override val chapterDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.ENGLISH)
override val listUrl: String get() = "/series"
override val tableMode: Boolean get() = false
}
@MangaSourceParser("MANHWALIST", "Manhwalist", "id")
class ManhwalistParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.MANHWALIST, pageSize = 24, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("manhwalist.in", null)
override val listUrl: String = "/manga"
override val tableMode: Boolean get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
}
@MangaSourceParser("KIRYUU", "Kiryuu", "id")
class KiryuuParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KIRYUU, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("kiryuu.id", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", idLocale)
}
@MangaSourceParser("TURKTOON", "Turktoon", "tr")
class TurktoonParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.TURKTOON, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("turktoon.com", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale("tr", "TR"))
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val mangaState = docs.selectFirst(".info-left .tsinfo div:contains(Durum)")?.lastElementChild()?.let {
when (it.text()) {
"Devam Ediyor" -> MangaState.ONGOING
"Tamamlandı" -> MangaState.FINISHED
else -> null
}
}
return super.parseInfoList(docs, manga, chapters).copy(state = mangaState)
}
}
@MangaSourceParser("WESTMANGA", "Westmanga", "id")
class WestmangaParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.WESTMANGA, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("westmanga.info", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
}
@MangaSourceParser("TEMPESTFANSUB", "Tempestfansub", "tr")
class TempestfansubParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.TEMPESTFANSUB, pageSize = 40, searchPageSize = 40) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("tempestscans.com", null)
override val listUrl: String get() = "/manga"
override val tableMode: Boolean get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale("tr", "TR"))
override suspend fun parseInfoTable(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Artist)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
isNsfw = docs.selectFirst(".postbody .alr") != null,
)
}
}
@MangaSourceParser("MANHWADESU", "ManhwaDesu", "id")
class ManhwadesuParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.MANHWADESU, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("manhwadesu.pro", arrayOf("manhwadesu.pro", "manhwadesu.org"))
override val listUrl: String get() = "/komik"
override val tableMode: Boolean get() = false
}
@MangaSourceParser("MANGATALE", "MangaTale", "id")
class MangaTaleParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.MANGATALE, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("mangatale.co", null)
override val listUrl: String get() = "/manga"
override val tableMode: Boolean get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
isNsfw = docs.selectFirst(".postbody .alr") != null,
)
}
}
@MangaSourceParser("DRAGONTRANSLATION", "DragonTranslation", "es")
class DragonTranslationParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.DRAGONTRANSLATION, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("dragontranslation.com", null)
override val listUrl: String get() = "/manga"
override val tableMode: Boolean get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
isNsfw = docs.selectFirst(".postbody .alr") != null,
)
}
}
@MangaSourceParser("ASURATR", "Asura Scans (tr)", "tr")
class AsuraTRParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.ASURATR, pageSize = 30, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("asurascanstr.com", null)
override val listUrl: String get() = "/manga"
override val tableMode: Boolean get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale("tr"))
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Yazar)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
isNsfw = docs.selectFirst(".postbody .alr") != null,
)
}
}
@MangaSourceParser("KOMIKTAP", "KomikTap", "id")
class KomikTapParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKTAP, pageSize = 15, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("92.87.6.124", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", idLocale)
}
@MangaSourceParser("KUMAPOI", "KumaPoi", "id")
class KumaPoiParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KUMAPOI, pageSize = 15, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("kumapoi.me", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
}
@MangaSourceParser("ASURASCANS", "Asura Scans", "en")
class AsuraScansParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.ASURASCANS, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("asurascans.com", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
isNsfw = docs.selectFirst(".postbody .alr") != null,
)
}
}
@MangaSourceParser("TOONHUNTER", "Toon Hunter", "th")
class ToonHunterParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.TOONHUNTER, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("toonhunter.com", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", sourceLocale)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
isNsfw = docs.selectFirst(".postbody .alr") != null,
)
}
}
@MangaSourceParser("COSMICSCANS", "CosmicScans", "en")
class CosmicScansParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.COSMICSCANS, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("cosmicscans.com", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
)
}
}
@MangaSourceParser("KOMIKLOKAL", "KomikLokal", "id")
class KomikLokalParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKLOKAL, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("komikmirror.art", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
)
}
}
@MangaSourceParser("KOMIKAV", "KomiKav", "id")
class KomiKavParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKAV, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("komikav.com", null)
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
author = infoElement?.selectFirst(".flex-wrap div:contains(Author)")?.lastElementSibling()?.text(),
tags = infoElement?.select(".wd-full .mgen > a")
?.mapNotNullToSet { getOrCreateTagMap()[it.text()] }
.orEmpty(),
)
}
}
@MangaSourceParser("KOMIKDEWASA", "KomikDewasa", "id")
class KomikDewasaParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKDEWASA, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("komikdewasa.us", arrayOf("komikdewasa.us", "komikdewasa.info"))
override val listUrl: String
get() = "/manga"
override val tableMode: Boolean
get() = false
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
override suspend fun parseInfoList(docs: Document, manga: Manga, chapters: List<MangaChapter>): Manga {
val infoElement = docs.selectFirst("div.infox")
return manga.copy(
chapters = chapters,
description = infoElement?.selectFirst("div.entry-content")?.html(),
)
}
}
@MangaSourceParser("MANGASUSU", "Mangasusu", "id")
class MangasusuParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.MANGASUSU, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("mangasusu.co.in", null)
override val listUrl: String
get() = "/project"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
}
@MangaSourceParser("KOMIKLAB", "KomikLab", "id")
class KomikLabParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKLAB, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("komiklab.com", null)
override val listUrl: String
get() = "/project"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
}
@MangaSourceParser("KOMIKINDO", "KomikIndo", "id")
class KomikIndoParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKINDO, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("komikindo.co", null)
override val listUrl: String
get() = "/project"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", idLocale)
}
@MangaSourceParser("KOMIKMANGA", "KomikManga", "id")
class KomikMangaParser(context: MangaLoaderContext) :
MangaReaderParser(context, MangaSource.KOMIKMANGA, pageSize = 20, searchPageSize = 10) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("komikhentai.co", null)
override val listUrl: String
get() = "/project"
override val tableMode: Boolean
get() = true
override val chapterDateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH)
}
}

View File

@@ -1,13 +1,18 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.multichan
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
internal abstract class ChanParser(source: MangaSource) : MangaParser(source) { internal abstract class ChanParser(
context: MangaLoaderContext,
source: MangaSource,
) : MangaParser(context, source), MangaParserAuthProvider {
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST, SortOrder.NEWEST,
@@ -15,13 +20,19 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL,
) )
override val authUrl: String
get() = "https://${domain}"
override val isAuthorized: Boolean
get() = context.cookieJar.getCookies(domain).any { it.name == "dle_user_id" }
override suspend fun getList( override suspend fun getList(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = domain
val url = when { val url = when {
!query.isNullOrEmpty() -> { !query.isNullOrEmpty() -> {
if (offset != 0) { if (offset != 0) {
@@ -29,16 +40,18 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
} }
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}" "https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
} }
!tags.isNullOrEmpty() -> tags.joinToString( !tags.isNullOrEmpty() -> tags.joinToString(
prefix = "https://$domain/tags/", prefix = "https://$domain/tags/",
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset", postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
separator = "+", separator = "+",
) { tag -> tag.key } ) { tag -> tag.key }
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset" else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
} }
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content") val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
?: parseFailed("Cannot find root") ?: doc.parseFailed("Cannot find root")
return root.select("div.content_row").mapNotNull { row -> return root.select("div.content_row").mapNotNull { row ->
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a") val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
?: return@mapNotNull null ?: return@mapNotNull null
@@ -73,16 +86,15 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = val root = doc.body().getElementById("dle-content") ?: doc.parseFailed("Cannot find root")
doc.body().getElementById("dle-content") ?: parseFailed("Cannot find root")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return manga.copy( return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"), description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"), largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr -> chapters = root.select("table.table_cha tr:gt(1)").mapChapters(reversed = true) { i, tr ->
val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href") val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
?: return@mapIndexedNotNull null ?: return@mapChapters null
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = tr.selectFirst("a")?.text().orEmpty(), name = tr.selectFirst("a")?.text().orEmpty(),
@@ -98,8 +110,8 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
val scripts = doc.select("script") val scripts = doc.select("script")
for (script in scripts) { for (script in scripts) {
val data = script.html() val data = script.html()
@@ -109,7 +121,7 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
} }
val json = data.substring(pos).substringAfter('[').substringBefore(';') val json = data.substring(pos).substringAfter('[').substringBefore(';')
.substringBeforeLast(']') .substringBeforeLast(']')
val domain = getDomain() val domain = domain
return json.split(",").mapNotNull { return json.split(",").mapNotNull {
it.trim() it.trim()
.removeSurrounding('"', '\'') .removeSurrounding('"', '\'')
@@ -120,21 +132,20 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }
} }
parseFailed("Pages list not found at ${chapter.url}") doc.parseFailed("Pages list not found at ${chapter.url}")
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain() val domain = domain
val doc = context.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml() val doc = webClient.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side") val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
?.select("ul")?.last() ?: throw ParseException("Cannot find root") ?.select("ul")?.last() ?: doc.parseFailed("Cannot find root")
return root.select("li.sidetag").mapToSet { li -> return root.select("li.sidetag").mapToSet { li ->
val a = li.children().last() ?: throw ParseException("a is null") val a = li.children().lastOrNull() ?: li.parseFailed("a is null")
MangaTag( MangaTag(
title = a.text().toTagName(), title = a.text().toTagName(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
@@ -143,6 +154,14 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
} }
} }
override suspend fun getUsername(): String {
val doc = webClient.httpGet("https://${domain}").parseHtml().body()
val root = doc.requireElementById("top_user")
val a = root.getElementsByAttributeValueContaining("href", "/user/").firstOrNull()
?: throw AuthRequiredException(source)
return a.attr("href").removeSuffix('/').substringAfterLast('/')
}
private fun getSortKey(sortOrder: SortOrder) = private fun getSortKey(sortOrder: SortOrder) =
when (sortOrder) { when (sortOrder) {
SortOrder.ALPHABETICAL -> "catalog" SortOrder.ALPHABETICAL -> "catalog"
@@ -160,4 +179,4 @@ internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
} }
private fun String.toTagName() = replace('_', ' ').toTitleCase() private fun String.toTagName() = replace('_', ' ').toTitleCase()
} }

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.parsers.site.multichan
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
@MangaSourceParser("HENCHAN", "Хентай-тян", "ru")
internal class HenChanParser(context: MangaLoaderContext) : ChanParser(context, MangaSource.HENCHAN) {
override val configKeyDomain = ConfigKey.Domain(
"y.hentaichan.live",
arrayOf("y.hentaichan.live", "xxx.hentaichan.live", "xx.hentaichan.live", "hentaichan.live", "hentaichan.pro"),
)
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
return super.getList(offset, query, tags, sortOrder).map {
it.copy(
coverUrl = it.coverUrl.replace("_blur", ""),
isNsfw = true,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = doc.body().requireElementById("dle-content")
val readLink = manga.url.replace("manga", "online")
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() ?: doc.parseFailed("Invalid tag")
MangaTag(
title = a.text().toTitleCase(),
key = a.attr("href").substringAfterLast('/'),
source = source,
)
} ?: manga.tags,
chapters = listOf(
MangaChapter(
id = generateUid(readLink),
url = readLink,
source = source,
number = 1,
uploadDate = 0L,
name = manga.title,
scanlator = null,
branch = null,
),
),
)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.multichan
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@MangaSourceParser("MANGACHAN", "Манга-тян", "ru") @MangaSourceParser("MANGACHAN", "Манга-тян", "ru")
internal class MangaChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.MANGACHAN) { internal class MangaChanParser(context: MangaLoaderContext) : ChanParser(context, MangaSource.MANGACHAN) {
override val configKeyDomain = ConfigKey.Domain("manga-chan.me", null) override val configKeyDomain = ConfigKey.Domain("manga-chan.me", null)
} }

View File

@@ -1,31 +1,27 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.multichan
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.parseHtml
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
@MangaSourceParser("YAOICHAN", "Яой-тян", "ru") @MangaSourceParser("YAOICHAN", "Яой-тян", "ru")
internal class YaoiChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.YAOICHAN) { internal class YaoiChanParser(context: MangaLoaderContext) : ChanParser(context, MangaSource.YAOICHAN) {
override val configKeyDomain = ConfigKey.Domain("yaoi-chan.me", null) override val configKeyDomain = ConfigKey.Domain("yaoi-chan.me", null)
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml() val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
val root = val root = doc.body().requireElementById("dle-content")
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
return manga.copy( return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"), description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"), largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
chapters = root.select("table.table_cha").flatMap { table -> chapters = root.select("table.table_cha").flatMap { table ->
table.select("div.manga") table.select("div.manga")
}.mapNotNull { it.selectFirst("a") }.reversed().mapIndexed { i, a -> }.mapNotNull { it.selectFirst("a") }.mapChapters(reversed = true) { i, a ->
val href = a.attrAsRelativeUrl("href") val href = a.attrAsRelativeUrl("href")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
@@ -40,4 +36,4 @@ internal class YaoiChanParser(override val context: MangaLoaderContext) : ChanPa
}, },
) )
} }
} }

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.rulib
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.parsers.site package org.koitharu.kotatsu.parsers.site.rulib
import androidx.collection.ArraySet import androidx.collection.ArraySet
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.PagedMangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -20,14 +20,14 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
internal open class MangaLibParser( internal open class MangaLibParser(
override val context: MangaLoaderContext, context: MangaLoaderContext,
source: MangaSource, source: MangaSource,
) : MangaParser(source), MangaParserAuthProvider { ) : PagedMangaParser(context, source, pageSize = 60), MangaParserAuthProvider {
override val configKeyDomain = ConfigKey.Domain("mangalib.me", null) override val configKeyDomain = ConfigKey.Domain("mangalib.me", null)
override val authUrl: String override val authUrl: String
get() = "https://${getDomain()}/login" get() = "https://${domain}/login"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.RATING, SortOrder.RATING,
@@ -37,19 +37,18 @@ internal open class MangaLibParser(
SortOrder.NEWEST, SortOrder.NEWEST,
) )
override suspend fun getList( override suspend fun getListPage(
offset: Int, page: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder, sortOrder: SortOrder,
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList() return if (page == searchPaginator.firstPage) search(query) else emptyList()
} }
val page = (offset / 60f).toIntUp()
val url = buildString { val url = buildString {
append("https://") append("https://")
append(getDomain()) append(domain)
append("/manga-list?dir=") append("/manga-list?dir=")
append(getSortKey(sortOrder)) append(getSortKey(sortOrder))
append("&page=") append("&page=")
@@ -59,8 +58,8 @@ internal open class MangaLibParser(
append(tag.key) append(tag.key)
} }
} }
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found") val root = doc.body().getElementById("manga-list") ?: doc.parseFailed("Root not found")
val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap") val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
?: return emptyList() ?: return emptyList()
return items.mapNotNull { card -> return items.mapNotNull { card ->
@@ -74,7 +73,7 @@ internal open class MangaLibParser(
author = null, author = null,
rating = RATING_UNKNOWN, rating = RATING_UNKNOWN,
url = href, url = href,
publicUrl = href.toAbsoluteUrl(a.host ?: getDomain()), publicUrl = href.toAbsoluteUrl(a.host ?: domain),
tags = emptySet(), tags = emptySet(),
state = null, state = null,
isNsfw = false, isNsfw = false,
@@ -84,36 +83,44 @@ internal open class MangaLibParser(
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val fullUrl = manga.url.toAbsoluteUrl(getDomain()) val fullUrl = manga.url.toAbsoluteUrl(domain)
val doc = context.httpGet("$fullUrl?section=info").parseHtml() val doc = webClient.httpGet("$fullUrl?section=info").parseHtml()
val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found") val root = doc.body().getElementById("main-page") ?: throw ParseException("Root not found", fullUrl)
val title = root.selectFirst("div.media-header__wrap")?.children() val title = root.selectFirst("div.media-header__wrap")?.children()
val info = root.selectFirst("div.media-content") val info = root.selectFirst("div.media-content")
val chaptersDoc = context.httpGet("$fullUrl?section=chapters").parseHtml() val chaptersDoc = webClient.httpGet("$fullUrl?section=chapters").parseHtml()
val scripts = chaptersDoc.select("script") val scripts = chaptersDoc.select("script")
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US) val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
var chapters: ArrayList<MangaChapter>? = null var chapters: ChaptersListBuilder? = null
scripts@ for (script in scripts) { scripts@ for (script in scripts) {
val raw = script.html().lines() val raw = script.html().lines()
for (line in raw) { for (line in raw) {
if (line.startsWith("window.__DATA__")) { if (line.startsWith("window.__DATA__")) {
val json = JSONObject(line.substringAfter('=').substringBeforeLast(';')) val json = JSONObject(line.substringAfter('=').substringBeforeLast(';'))
val list = json.getJSONObject("chapters").getJSONArray("list") val list = json.getJSONObject("chapters").getJSONArray("list")
val id = json.optJSONObject("user")?.getLong("id")?.toString() ?: "not"
val total = list.length() val total = list.length()
chapters = ArrayList(total) chapters = ChaptersListBuilder(total)
for (i in 0 until total) { for (i in 0 until total) {
val item = list.getJSONObject(i) val item = list.getJSONObject(i)
val chapterId = item.getLong("chapter_id") val chapterId = item.getLong("chapter_id")
val scanlator = item.getStringOrNull("username") val scanlator = item.getStringOrNull("username")
val url = buildString { val url = buildString {
append(manga.url) if (isAuthorized) {
append("/v") append(manga.url)
append(item.getInt("chapter_volume")) append("/v")
append("/c") append(item.getInt("chapter_volume"))
append(item.getString("chapter_number")) append("/c")
@Suppress("BlockingMethodInNonBlockingContext") // lint issue append(item.getString("chapter_number"))
append('/') append("?ui=")
append(item.optString("chapter_string")) append(id)
} else {
append(manga.url)
append("/v")
append(item.getInt("chapter_volume"))
append("/c")
append(item.getString("chapter_number"))
}
} }
val nameChapter = item.getStringOrNull("chapter_name") val nameChapter = item.getStringOrNull("chapter_name")
val volume = item.getInt("chapter_volume") val volume = item.getInt("chapter_volume")
@@ -160,18 +167,18 @@ internal open class MangaLibParser(
} ?: manga.tags, } ?: manga.tags,
isNsfw = isNsfw(doc), isNsfw = isNsfw(doc),
description = info?.selectFirst("div.media-description__text")?.html(), description = info?.selectFirst("div.media-description__text")?.html(),
chapters = chapters, chapters = chapters?.toList(),
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.toAbsoluteUrl(getDomain()) val fullUrl = chapter.url.toAbsoluteUrl(domain)
val doc = context.httpGet(fullUrl).parseHtml() val doc = webClient.httpGet(fullUrl).parseHtml()
if (doc.location().endsWith("/register")) { if (doc.location().substringBefore('?').endsWith("/register")) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
val scripts = doc.head().select("script") val scripts = doc.head().select("script")
val pg = (doc.body().getElementById("pg")?.html() ?: parseFailed("Element #pg not found")) val pg = (doc.body().getElementById("pg")?.html() ?: doc.parseFailed("Element #pg not found"))
.substringAfter('=') .substringAfter('=')
.substringBeforeLast(';') .substringBeforeLast(';')
val pages = JSONArray(pg) val pages = JSONArray(pg)
@@ -195,18 +202,17 @@ internal open class MangaLibParser(
id = generateUid(pageUrl), id = generateUid(pageUrl),
url = pageUrl, url = pageUrl,
preview = null, preview = null,
referer = fullUrl,
source = source, source = source,
) )
} }
} }
} }
throw ParseException("Script with info not found") throw ParseException("Script with info not found", fullUrl)
} }
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val url = "https://${getDomain()}/manga-list" val url = "https://${domain}/manga-list"
val doc = context.httpGet(url).parseHtml() val doc = webClient.httpGet(url).parseHtml()
val scripts = doc.body().select("script") val scripts = doc.body().select("script")
for (script in scripts) { for (script in scripts) {
val raw = script.html().trim() val raw = script.html().trim()
@@ -224,28 +230,28 @@ internal open class MangaLibParser(
return result return result
} }
} }
throw ParseException("Script with genres not found") throw ParseException("Script with genres not found", url)
} }
override val isAuthorized: Boolean override val isAuthorized: Boolean
get() { get() {
return context.cookieJar.getCookies(getDomain()).any { return context.cookieJar.getCookies(domain).any {
it.name.startsWith("remember_web_") it.name.startsWith("remember_web_")
} }
} }
override suspend fun getUsername(): String { override suspend fun getUsername(): String {
val body = context.httpGet("https://${getDomain()}/messages").parseHtml().body() val body = webClient.httpGet("https://${LibConst.LIB_SOCIAL_LINK}/messages").parseHtml().body()
if (body.baseUri().endsWith("/login")) { if (body.baseUri().endsWith("/login")) {
throw AuthRequiredException(source) throw AuthRequiredException(source)
} }
return body.selectFirst(".profile-user__username")?.text() ?: parseFailed("Cannot find username") return body.selectFirst(".profile-user__username")?.text() ?: body.parseFailed("Cannot find username")
} }
protected open fun isNsfw(doc: Document): Boolean { protected open fun isNsfw(doc: Document): Boolean {
val sidebar = doc.body().run { val sidebar = doc.body().run {
selectFirst(".media-sidebar") ?: selectFirst(".media-info") selectFirst(".media-sidebar") ?: selectFirst(".media-info")
} ?: parseFailed("Sidebar not found") } ?: doc.parseFailed("Sidebar not found")
return sidebar.getElementsContainingOwnText("18+").isNotEmpty() return sidebar.getElementsContainingOwnText("18+").isNotEmpty()
} }
@@ -259,8 +265,8 @@ internal open class MangaLibParser(
} }
private suspend fun search(query: String): List<Manga> { private suspend fun search(query: String): List<Manga> {
val domain = getDomain() val domain = domain
val json = context.httpGet("https://$domain/search?type=manga&q=$query") val json = webClient.httpGet("https://$domain/search?type=manga&q=$query")
.parseJsonArray() .parseJsonArray()
return json.mapJSON { jo -> return json.mapJSON { jo ->
val slug = jo.getString("slug") val slug = jo.getString("slug")
@@ -287,4 +293,10 @@ internal open class MangaLibParser(
@MangaSourceParser("MANGALIB", "MangaLib", "ru") @MangaSourceParser("MANGALIB", "MangaLib", "ru")
class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB) class Impl(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.MANGALIB)
}
object LibConst {
val LIB_SOCIAL_LINK = "lib.social"
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.parsers.site.rulib
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.MangaSource
@MangaSourceParser("YAOILIB", "YaoiLib", "ru")
internal class YaoiLibParser(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.YAOILIB) {
override val configKeyDomain = ConfigKey.Domain("yaoilib.me", null)
override fun isNsfw(doc: Document) = true
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.parsers.util
fun <T : Any> T?.assertNotNull(name: String): T? {
assert(this != null) {
"Value $name is null"
}
return this
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.parsers.util
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.model.MangaChapter
@InternalParsersApi
inline fun <T> List<T>.mapChapters(
reversed: Boolean = false,
transform: (index: Int, T) -> MangaChapter?,
): List<MangaChapter> {
val builder = ChaptersListBuilder(collectionSize())
var index = 0
val elements = if (reversed) this.asReversed() else this
for (item in elements) {
if (builder.add(transform(index, item))) {
index++
}
}
return builder.toList()
}
@PublishedApi
internal fun <T> Iterable<T>.collectionSize(): Int {
return if (this is Collection<*>) this.size else 10
}
@PublishedApi
internal class ChaptersListBuilder(initialSize: Int) {
private val ids = HashSet<Long>(initialSize)
private val list = ArrayList<MangaChapter>(initialSize)
fun add(chapter: MangaChapter?): Boolean {
return chapter != null && ids.add(chapter.id) && list.add(chapter)
}
operator fun plusAssign(chapter: MangaChapter?) {
add(chapter)
}
fun reverse() {
list.reverse()
}
fun toList(): List<MangaChapter> = list
}

View File

@@ -9,10 +9,7 @@ import okhttp3.HttpUrl
private const val SCHEME_HTTPS = "https" private const val SCHEME_HTTPS = "https"
fun CookieJar.insertCookies(domain: String, vararg cookies: String) { fun CookieJar.insertCookies(domain: String, vararg cookies: String) {
val url = HttpUrl.Builder() val url = safeUrlOf(domain) ?: return
.scheme(SCHEME_HTTPS)
.host(domain)
.build()
saveFromResponse( saveFromResponse(
url, url,
cookies.mapNotNull { cookies.mapNotNull {
@@ -22,18 +19,12 @@ fun CookieJar.insertCookies(domain: String, vararg cookies: String) {
} }
fun CookieJar.insertCookie(domain: String, cookie: Cookie) { fun CookieJar.insertCookie(domain: String, cookie: Cookie) {
val url = HttpUrl.Builder() val url = safeUrlOf(domain) ?: return
.scheme(SCHEME_HTTPS)
.host(domain)
.build()
saveFromResponse(url, listOf(cookie)) saveFromResponse(url, listOf(cookie))
} }
fun CookieJar.getCookies(domain: String): List<Cookie> { fun CookieJar.getCookies(domain: String): List<Cookie> {
val url = HttpUrl.Builder() val url = safeUrlOf(domain) ?: return emptyList()
.scheme(SCHEME_HTTPS)
.host(domain)
.build()
return loadForRequest(url) return loadForRequest(url)
} }
@@ -47,4 +38,13 @@ fun CookieJar.copyCookies(oldDomain: String, newDomain: String, names: Array<Str
} }
url.host(newDomain) url.host(newDomain)
saveFromResponse(url.build(), cookies) saveFromResponse(url.build(), cookies)
}
private fun safeUrlOf(domain: String): HttpUrl? = try {
HttpUrl.Builder()
.scheme(SCHEME_HTTPS)
.host(domain)
.build()
} catch (_: IllegalArgumentException) {
null
} }

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.parsers.util
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
fun Iterable<Job>.cancelAll(cause: CancellationException? = null) {
forEach { it.cancel(cause) }
}
suspend fun <T> Iterable<Deferred<T>>.awaitFirst(): T {
return channelFlow {
for (deferred in this@awaitFirst) {
launch {
send(deferred.await())
}
}
}.first().also { this@awaitFirst.cancelAll() }
}
suspend fun <T> Collection<Deferred<T>>.awaitFirst(condition: (T) -> Boolean): T {
return channelFlow {
for (deferred in this@awaitFirst) {
launch {
val result = deferred.await()
if (condition(result)) {
send(result)
}
}
}
}.first().also { this@awaitFirst.cancelAll() }
}

View File

@@ -0,0 +1,92 @@
package org.koitharu.kotatsu.parsers.util
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.model.Favicon
import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.network.WebClient
import org.koitharu.kotatsu.parsers.util.json.mapJSON
class FaviconParser(
private val webClient: WebClient,
private val domain: String,
) {
suspend fun parseFavicons(): Favicons {
val url = "https://$domain"
val doc = webClient.httpGet(url).parseHtml()
val result = HashSet<Favicon>()
val manifestLink = doc.getElementsByAttributeValue("rel", "manifest").firstOrNull()
?.attrAsAbsoluteUrlOrNull("href")
if (manifestLink != null) {
result += parseManifest(manifestLink)
}
val links = doc.getElementsByAttributeValueContaining("rel", "icon")
links.mapNotNullTo(result) { link ->
parseLink(link)
}
if (result.isEmpty()) {
result.add(createFallback())
}
return Favicons(result, url)
}
private fun parseLink(link: Element): Favicon? {
val href = link.attrAsAbsoluteUrlOrNull("href")
if (href == null || href.endsWith('/')) {
return null
}
val sizes = link.attr("sizes")
return Favicon(
url = href,
size = parseSize(sizes),
rel = link.attrOrNull("rel"),
)
}
private fun parseSize(sizes: String): Int {
if (sizes.isEmpty() || sizes == "any") {
return 0
}
return sizes.substringBefore(' ')
.split('x', 'X', '*')
.firstNotNullOfOrNull { it.toIntOrNull() }
?: 0
}
private suspend fun parseManifest(url: String): List<Favicon> {
val json = webClient.httpGet(url).parseJson()
val icons = json.getJSONArray("icons")
return icons.mapJSON { jo ->
Favicon(
url = jo.getString("src").resolveLink(),
size = parseSize(jo.getString("sizes")),
rel = null,
)
}
}
private fun createFallback(): Favicon {
val href = "https://$domain/favicon.ico"
return Favicon(
url = href,
size = 0,
rel = null,
)
}
private fun String.resolveLink(): String {
return when {
startsWith("http:") || startsWith("https:") -> {
this
}
startsWith('/') -> {
"https://$domain$this"
}
else -> {
"https://$domain/$this"
}
}
}
}

View File

@@ -4,6 +4,7 @@ package org.koitharu.kotatsu.parsers.util
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import org.jsoup.select.Selector import org.jsoup.select.Selector
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -31,7 +32,7 @@ fun Element.attrOrNull(attributeKey: String) = attr(attributeKey).takeUnless { i
*/ */
fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? { fun Element.attrAsRelativeUrlOrNull(attributeKey: String): String? {
val attr = attr(attributeKey).trim() val attr = attr(attributeKey).trim()
if (attr.isEmpty()) { if (attr.isEmpty() || attr.startsWith("data:")) {
return null return null
} }
if (attr.startsWith("/")) { if (attr.startsWith("/")) {
@@ -62,7 +63,7 @@ fun Element.attrAsRelativeUrl(attributeKey: String): String {
*/ */
fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? { fun Element.attrAsAbsoluteUrlOrNull(attributeKey: String): String? {
val attr = attr(attributeKey).trim() val attr = attr(attributeKey).trim()
if (attr.isEmpty()) { if (attr.isEmpty() || attr.startsWith("data:")) {
return null return null
} }
return (baseUri().toHttpUrlOrNull()?.newBuilder(attr) ?: return null).toString() return (baseUri().toHttpUrlOrNull()?.newBuilder(attr) ?: return null).toString()
@@ -90,14 +91,31 @@ fun Element.styleValueOrNull(property: String): String? {
return css.substringAfter(':').removeSuffix(';').trim() return css.substringAfter(':').removeSuffix(';').trim()
} }
/**
* Like a `expectFirst` but with detailed error message
*/
fun Element.selectFirstOrThrow(cssQuery: String): Element { fun Element.selectFirstOrThrow(cssQuery: String): Element {
return Selector.selectFirst(cssQuery, this) ?: throw ParseException("Cannot find \"$cssQuery\"") return Selector.selectFirst(cssQuery, this) ?: throw ParseException("Cannot find \"$cssQuery\"", baseUri())
}
fun Element.selectOrThrow(cssQuery: String): Elements {
return Selector.select(cssQuery, this).ifEmpty {
throw ParseException("Empty result for \"$cssQuery\"", baseUri())
}
} }
fun Element.requireElementById(id: String): Element { fun Element.requireElementById(id: String): Element {
return getElementById(id) ?: throw ParseException("Cannot find \"#$id\"") return getElementById(id) ?: throw ParseException("Cannot find \"#$id\"", baseUri())
} }
fun Element.selectLast(cssQuery: String): Element? { fun Element.selectLast(cssQuery: String): Element? {
return select(cssQuery).lastOrNull() return select(cssQuery).lastOrNull()
} }
fun Element.selectLastOrThrow(cssQuery: String): Element {
return selectLast(cssQuery) ?: throw ParseException("Cannot find \"$cssQuery\"", baseUri())
}
fun Element.textOrNull(): String? = text().takeUnless { it.isEmpty() }
fun Element.ownTextOrNull(): String? = ownText().takeUnless { it.isEmpty() }

View File

@@ -0,0 +1,78 @@
package org.koitharu.kotatsu.parsers.util
import okhttp3.HttpUrl
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.InternalParsersApi
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaTag
/**
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
* @param url must be relative url, without a domain
* @see [Manga.id]
* @see [MangaChapter.id]
* @see [MangaPage.id]
*/
@InternalParsersApi
fun MangaParser.generateUid(url: String): Long {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
return h
}
/**
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
* @param id an internal identifier
* @see [Manga.id]
* @see [MangaChapter.id]
* @see [MangaPage.id]
*/
@InternalParsersApi
fun MangaParser.generateUid(id: Long): Long {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
h = 31 * h + id
return h
}
@InternalParsersApi
fun Element.parseFailed(message: String? = null): Nothing {
throw ParseException(message, ownerDocument()?.location() ?: baseUri(), null)
}
@InternalParsersApi
fun Set<MangaTag>?.oneOrThrowIfMany(): MangaTag? {
return when {
isNullOrEmpty() -> null
size == 1 -> first()
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
}
}
val MangaParser.domain: String
get() {
return config[configKeyDomain]
}
fun MangaParser.getDomain(subdomain: String): String {
val domain = domain
return subdomain + "." + domain.removePrefix("www.")
}
fun MangaParser.urlBuilder(): HttpUrl.Builder {
return HttpUrl.Builder()
.scheme("https")
.host(domain)
}

View File

@@ -4,16 +4,26 @@ package org.koitharu.kotatsu.parsers.util
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call import okhttp3.Call
import okhttp3.Headers
import okhttp3.Response import okhttp3.Response
suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation ->
val callback = ContinuationCallCallback(this, continuation) val callback = ContinuationCallCallback(this, continuation)
enqueue(callback) enqueue(callback)
continuation.invokeOnCancellation(callback) continuation.invokeOnCancellation(callback)
} }
val Response.mimeType: String? val Response.mimeType: String?
get() = body?.contentType()?.run { "$type/$subtype" } get() = body?.contentType()?.run { "$type/$subtype" }
val Response.contentDisposition: String? val Response.contentDisposition: String?
get() = header("Content-Disposition") get() = header("Content-Disposition")
fun Headers.Builder.mergeWith(other: Headers, replaceExisting: Boolean): Headers.Builder {
for ((name, value) in other) {
if (replaceExisting || this[name] == null) {
this[name] = value
}
}
return this
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.parsers.util
import androidx.collection.SparseArrayCompat
import androidx.collection.set
class Paginator constructor(private val initialPageSize: Int) {
var firstPage = 1
private var pages = SparseArrayCompat<Int>()
fun getPage(offset: Int): Int {
if (offset == 0) { // just an optimization
return firstPage
}
pages[offset]?.let { return it }
val pageSize = initialPageSize
val intPage = offset / pageSize
val tail = offset % pageSize
return intPage + firstPage + if (tail == 0) 0 else 1
}
fun onListReceived(offset: Int, page: Int, count: Int) {
pages[offset + count] = if (count > 0) page + 1 else page
}
}

View File

@@ -46,6 +46,12 @@ fun Response.parseJsonArray(): JSONArray = try {
closeQuietly() closeQuietly()
} }
fun Response.parseRaw(): String = try {
requireBody().string()
} finally {
closeQuietly()
}
/** /**
* Convert url to relative if it is on [domain] * Convert url to relative if it is on [domain]
* @return an url relative to the [domain] or absolute, if domain is mismatching * @return an url relative to the [domain] or absolute, if domain is mismatching
@@ -67,12 +73,25 @@ fun String.toAbsoluteUrl(domain: String): String = when {
else -> this else -> this
} }
fun concatUrl(host: String, path: String): String {
val hostWithSlash = host.endsWith('/')
val pathWithSlash = path.startsWith('/')
return when {
hostWithSlash && pathWithSlash -> host + path.drop(1)
!hostWithSlash && !pathWithSlash -> "$host/$path"
else -> host + path
}
}
fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) { fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) {
assert(false) { "Date string is null or empty" }
0L 0L
} else { } else {
runCatching { runCatching {
parse(str)?.time ?: 0L parse(str)?.time ?: 0L
}.onFailure {
assert(false) { "Cannot parse date $str: ${it.message}" }
}.getOrDefault(0L) }.getOrDefault(0L)
} }
private fun Response.requireBody(): ResponseBody = requireNotNull(body) { "Response body is null" } private fun Response.requireBody(): ResponseBody = requireNotNull(body) { "Response body is null" }

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.parsers.util
import kotlinx.coroutines.CancellationException
inline fun <T, R> T.runCatchingCancellable(block: T.() -> R): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}

Some files were not shown because too many files have changed in this diff Show More