Compare commits

...

234 Commits

Author SHA1 Message Date
abubaca4
d9df2955e9
[RU]Nudemoon fix image list parse (#11757)
All checks were successful
CI / Prepare job (push) Successful in 5s
CI / Build individual modules (push) Successful in 6m29s
CI / Publish repo (push) Successful in 1m42s
* Increment version code from 25 to 26

* Update image selection criteria in pageListParse
2025-11-22 22:18:49 +00:00
Cuong-Tran
9dbe2bd9ac
Koharu: Fix API domain & remove non-relevant domain (#11754)
* Remove hdoujin.net

* Fix API domain
2025-11-22 22:18:49 +00:00
Vetle Ledaal
ac21e59f0d
WeLoveMangaOne → Love4u: update domain, icon (#11731) 2025-11-22 22:18:49 +00:00
Vetle Ledaal
d97622117c
Violet Scans: update domain (#11730) 2025-11-22 22:18:49 +00:00
David Brochero
d3e7c52053
fix(utoon): don't use future upload date (#11713) 2025-11-22 22:18:48 +00:00
lamaxama
64b3c18350
Kagane: add support for Tachimanga (#11710)
* Kagane: add support for Tachimanga

* cache cert
2025-11-22 22:18:48 +00:00
manti
eec57bbf65
BookWalker Global: high-res thumbnails and details from api (#11654)
* thumbnails

* get details from api

* typo

* adjustments

* use select
2025-11-22 22:18:48 +00:00
bapeey
e595780f24
Hiperdex: Fix http 403 and blocked in WebView (#11744)
* fix 403

* bump

* use cf-ray header
2025-11-22 22:18:48 +00:00
novenary
e17d796fc1
Drop fakeimg (#11743)
* xkcd: drop fakeimg

* smbc: drop fakeimg

* mehgazone: drop fakeimg

* swordscomic: drop fakeimg

* buttsmithy: drop fakeimg

* xkcd: better thumbnail

It's unclear what the original source for it is but it's all over the
web.

Link: https://libguides.davenportlibrary.com/comicsforallages/xkcd

* smbc, xkcd: implement fetchMangaDetails properly

* Update src/en/saturdaymorningbreakfastcomics/build.gradle

* Update src/all/xkcd/build.gradle

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:18:48 +00:00
Chopper
8bd7ed9a90
YugenMangas: Fix content loading (#11742)
* Fix content loading

* Remove escape quotes and capture group

* Update domain
2025-11-22 22:18:48 +00:00
bapeey
a085a3cb0d
NineManga(es): Fix pages not loading (#11734)
fix page list
2025-11-22 22:18:48 +00:00
Felipe Ávila
4c6b86973c
Fix Mangastop: adding headers (#11724)
* add headers

* bump version code
2025-11-22 22:18:48 +00:00
Felipe Ávila
0bfc8ce9ae
Add new source: Lycantoons (#11707)
* remove source morta

* add lycantoons

* add LycanToons

* remove o CARAI do espaço q ficou

* formatando o comentario Latest pra ficar tudo igual oh ceus

* reviews

* remove serial name

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* fixing "," by default

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* ajeitando locale

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* parse simular a GreenShit + reviews

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:18:48 +00:00
anewadventure
948ff10002
Koharu: Add mirror selection to preference page (#11588)
* Add mirror pref

* Koharu: Add more mirror sites

* Koharu: Raised extVersionCode

* Koharu: Implement index fallback

* Koharu: Remove preferences migration
2025-11-22 22:18:48 +00:00
anewadventure
fdc8e29671
SpyFakku: Add mirror selection to preferences page (#11373)
* Add mirror selection to SpyFakku

* Move this to resolve null pointer

* mirror pref + trust certs for airdns domain + anibus bypass for airdns domain

* inspector crash

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:18:48 +00:00
Hasan Türkyılmaz
709609fb70
Add Tenshi Manga (#10922) 2025-11-22 22:18:48 +00:00
Chopper
8c230e25c3
Add MangaLivreTo (#11706)
* Add MangaLivreTo

* Remove unneeded code
2025-11-22 22:18:48 +00:00
Luqman
df3319f128
Comix: SFW by default (#11696) 2025-11-22 22:18:48 +00:00
manti
2f9626a2f7
Magazine Pocket: fix descrambler and refactor (#11687)
* fix descrambler and refactor

* getChapterUrl

* toSChapter
2025-11-22 22:18:48 +00:00
manti
b1ee9c1589
Gangan Online: Fix MissingFieldException (#11685)
Fix MissingFieldException
2025-11-22 22:18:48 +00:00
Creepler13
f28161c28b
Add SubManhwa (#11683) 2025-11-22 22:18:48 +00:00
mrtear
6eae846dc8
feat(comix): Add rating score, NSFW toggle, and filter fix (#11682)
* bump

* refactor: fix **type** filter param to match api

* add configurable rating score display

* rearrange filter

* add preference to hide NSFW content
2025-11-22 22:18:48 +00:00
Felipe Gangorra
f59060dc92
Fix: MangaLivre: erro 403 and 404 (#11651)
* Fix: MangaLivre: erro 403 and 404

* Update src/pt/mangalivre/build.gradle

Co-authored-by: bapeey <90949336+bapeey@users.noreply.github.com>

* Remove the complex interceptor

---------

Co-authored-by: bapeey <90949336+bapeey@users.noreply.github.com>
2025-11-22 22:18:48 +00:00
Felipe Ávila
1ed99e7967
Mediocretoons: update cdn url (#11672)
* att URL CDN

* bump versioncode
2025-11-22 22:18:48 +00:00
EgoMaw
7bf99f4bd0
Add Comix (#11658)
* Comix init

* Add genre, author and artist parsing

* add search and filters

* hardcode genres and themes

* Add the ability to retrieve chapter pages (#1)

Co-authored-by: EgoMaw <dev@egomaw.net>

* Add a pref for deduplicating chapters, cleanup code

* fix api path

* apparently sometimes the synopsis can be null, or an empty string

* changes according to feedback

* fix pagination, and dont call func inside map when not needed

* Fix chapter list parsing ignoring first page and remove unused fields from DTOs

* dont use custom Json instance

---------

Co-authored-by: Hiirbaf <42479509+Hiirbaf@users.noreply.github.com>
2025-11-22 22:18:45 +00:00
Luqman
1e768ace94
NatsuId multisrc: Natsu (id), Ikiru (id), Kiryuu (id), rawkuma (ja) (#11592)
* 1

* 2

* 3

* fix ikiru with json cleaner

* cleaning

* also clean json for parsing genre list

* dse

* saving manga ID to desc so only use call once

* fix okhttp client building override and cleanup

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:18:45 +00:00
Mohamed Waled Farag Youssef
6bced1de64
Updating Hwago Indonesia extension url (#11653)
* Updating Hwago Indonesia extension url

* Remove the trailing slash
2025-11-22 22:18:45 +00:00
Gusti Randa
ef51432e8a
Update Kiryuu URL from kiryuu02 to kiryuu03 (#11657)
* Update base URL and version code in build.gradle

* Update Kiryuu URL from kiryuu02 to kiryuu03
2025-11-22 22:18:45 +00:00
Ata
fc81248cb2
UzayManga: Update CDN url (#11652)
* UzayManga: Update CDN_URL

* UzayManga: bump version
2025-11-22 22:18:45 +00:00
are-are-are
c35f9f6111
VlogTruyen: Update domain (#11663) 2025-11-22 22:18:45 +00:00
SilverBeamx
d8f1be1bb4
Hentaifox: fix "Top" categories filter (#11648)
* Add new header needed for Sidebar requests

* Increment version

* Add trailing slash to Referer url

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:18:45 +00:00
Cuong-Tran
4dd6a75e10
MadTheme: Fix cover (#11645)
* MadTheme: Fix cover

* Remove dead sources

* Revert "Remove dead sources"

This reverts commit aa04da638a93e40e1c28eaa2d8f4fcbf2e5681d9.
2025-11-22 22:18:45 +00:00
are-are-are
1a0a2fa4bf
TruyenQQ: Missed unwrap text css 'p' (#11647)
TruyenQQ Missed unwrap css p

no need to bump version
2025-11-22 22:18:45 +00:00
Romain
43a4da5a3b
Fix base URL for Poseidon Scans extension (#11622)
* Fix base URL for Poseidon Scans extension

* Bump extension version
2025-11-22 22:18:45 +00:00
are-are-are
cc1631633f
TruyenQQ: Update baseUrl & update code (#11639)
* TruyenQQ: Update baseUrl & update code

* Unwrap text
2025-11-22 22:18:45 +00:00
abubaca4
b30df94837
[RU]Nudemoon more precise image filtering (#11644)
* More precise image filtering

* Bump version code from 24 to 25
2025-11-22 22:18:45 +00:00
Kirill Kvit
c0dfbb7c13
FlameComics: update api contract (#11643)
* Update api contract

Update last_edit type to match updated FlameComics API

* Fix extVersionCode
2025-11-22 22:18:45 +00:00
AwkwardPeak7
60e78280c8
MangaTaro: migrate to new chapter endpoint (#11636) 2025-11-22 22:18:45 +00:00
mrtear
1aa1119924
add ParadiseScans (#11627)
paradisescans
2025-11-22 22:18:45 +00:00
mrtear
f9e9cc66c8
add LagoonScans (#11626)
lagoonscans
2025-11-22 22:18:45 +00:00
mrtear
afed002091
add SirenScans FR (#11625)
siren scans fr
2025-11-22 22:18:45 +00:00
Ata
cc885e7353
Manga-TR: Fix popular/latest, chapter list and page loading (#11620)
* Manga-TR: Fix popular manga parsing

* Manga-TR: Fix manga details parsing

* MangaTR: Fetch only manga

* MangaTR: Update chapter parsing

* MangaTR: update page list parsing

* MangaTR: bump version

* MangaTR: Fix manga detail description parsing

* MangaTR: Fix genre parsing
2025-11-22 22:18:45 +00:00
Js0n
f17d40a1c1
fix(rawinu): ad break page (#11600) 2025-11-22 22:18:45 +00:00
AwkwardPeak7
b617a1a28b
bump version 2025-11-22 22:18:45 +00:00
AwkwardPeak7
1251cbb432
Revert "AsuraScans: add auth and premium support (#11339)"
This reverts commit efa420949cea319a0389f7102f7e1ee83f8d1b50.
2025-11-22 22:18:45 +00:00
Cuong-Tran
2a7a3f0e2b
HiperDex: Fix chapter list (#11612)
Fix chapter list
2025-11-22 22:18:45 +00:00
Cuong-Tran
55ae73a0d6
Kagane: Add Hiatus status (#11613)
Add Hiatus status
2025-11-22 22:18:45 +00:00
bapeey
3946637e7b
LeerCapitulo: Don't throw exception if deobfuscation failed (#11604)
* fix crash

* bump
2025-11-22 22:18:44 +00:00
Cuong-Tran
b8154e7698
Kagane: Add excluded genres & Fix chapter number (#11537)
* Don't use DTO chapter number as it is , not actual chapter number

* Add Exclude Genres preference

* pump version

* Move 'Show scanlations' to preferences

* Add source to tags so it can be searched with filter by clicking on it

* Don't sort by relevant if filtering without query string

* Some sources prefer 'number_sort'

* catching error

* optimize
2025-11-22 22:18:44 +00:00
KenjieDec
9194e31208
HDoujin | Added HDoujin (#11548)
* Added HDoujin

* Update build.gradle

Wrong version, unused dependency

* Page Filter

* Fixed Sort Filter

* Apply AwkwardPeak's Suggestion

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:18:44 +00:00
Gauthier
68af18e453
[Pepper&Carrot] multiples fixes (#11559)
* fix: cannot retrieve mangas when framagit.org is unreachable

since it is used only for translated titles, we can omit that if it is unreachable

* fix: fails to retrieve chapter list because the website changed

* fix: page list parsing

* feat: add support for mini theather fantasy

* bump version

* refactor: use tryParse util

* refactor: remove unnecessary !!
2025-11-22 22:18:44 +00:00
Mohamed Waled Farag Youssef
bd530edb7d
Updating Onma arabic extension url (#11591) 2025-11-22 22:18:44 +00:00
Dan Bastin
7b11937c24
LeerCapitulo - update url increment gradle version (#11595)
update url increment grade version
2025-11-22 22:18:44 +00:00
Genzales6
84b9ea4ce6
Arya Scans / HaremDeKira Update Domains (#11583)
* AryaScans

* HaremDeKira
2025-11-22 22:18:44 +00:00
manti
75b19b7006
Add Hyakuro (#11573)
add hyakuro
2025-11-22 22:18:44 +00:00
Chopper
12ea764882
Remove dead sources (#11554)
* Remove YushukeMangas

* Remove WinterSun

* Remove XsScan

* Remove NazarickScan

* Remove LimitedTimeProject

* Remove InfinyxScan

* Remove DianxiaTraducoes

* Remove MangaOnline

* Remove LichMangas

* Remove LerHentai

* Remove Bruttal

* Remove ArgosHentai
2025-11-22 22:18:44 +00:00
Gael Pérez
e54c91bb88
update barmanga isNsfw (#11528)
* Update BarManga.kt

* Update BarManga.kt

* Update build.gradle

* Update build.gradle
2025-11-22 22:18:44 +00:00
Eradrim
14d8020d97
Ken scans URL change #11499 (#11506)
* Ken scans URL change #11499

* Update KenScans.kt
2025-11-22 22:18:44 +00:00
dragon-masterk
809848ab33
Drake Scans: Add option to hide paid chapters (#11504)
* added support for paid chapter filtering

* updated extension version correctly

* using config screen correctly with orginal theme class

* Update src/en/drakescans/src/eu/kanade/tachiyomi/extension/en/drakescans/DrakeScans.kt

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:18:44 +00:00
Cuong-Tran
5578344e19
HentaiCube: update base URL (#11498)
* HentaiCube: update base URL

* Validate custom domain and apply directly

* update base URL on redirect
2025-11-22 22:18:44 +00:00
Cuong-Tran
4c73cc5e75
Add Hangtruyen (#11497)
* add HangTruyen

* Update

* refactor to ParseHttpSource

* Using custom domain

* Add validation for custom domain input in HangTruyen settings

* Auto update custom domain when redirected

* Add filters

* Fix latest/popular

* using commit to avoid race conditions

* minor fix

* Fix domain regex & dateTime parsing

* Synchronize preference access

* Refactor genre fetching logic to use atomic variables for thread safety

* apply review

* typo

* remove all trim

---------

Co-authored-by: siritami <102145692+FiorenMas@users.noreply.github.com>
2025-11-22 22:18:44 +00:00
anewadventure
64cdf418ce
Change URL Hayalistic (#11405) (#11489)
* Change URL Hayalistic (#11405)

* Remove trailing slash

* Remove yet another trailing slash
2025-11-22 22:18:44 +00:00
manti
5e88baecd1
Add CiaoPlus (#11480)
* add ciaoplus

* use api and refactor

* getMangaUrl
2025-11-22 22:18:44 +00:00
KenjieDec
afbb0796d9
WNACG | Added Tag Filter, Added Some New Category Options (#11478)
* Added Tag Filter, Added Some New Category Options

* Removed unused imports

* Applied AwkwardPeak7's Suggestions
2025-11-22 22:18:44 +00:00
rsyh93
3c22c12ad7
MyReadingManga: Update URLs and Selectors for new website layout (#11454)
* Update all URL and Page attr references

* upversion

* Remove old commented line

* Remove question comment on /popular
2025-11-22 22:18:44 +00:00
Felipe Ávila
151718b605
Mediocretoons: update for new site (#11452)
* feat: nova lib para mediocretoons

* Movendo código do multisrc orangeshit para o pacote mediocretoons

* Removendo multisrc orangeshit

* Atualizando .gitignore

* Adicionando filtros completos

* fix: correção das review
- add novas url api e imagens temporarias, até retornar as originais
- add name.toSlug para wevbiew

* Atualizar o .gitignore

* Update .gitignore

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:18:44 +00:00
KenjieDec
91115ac93f
ComickLive | Add option to manually input tags (#11448)
* Add option to manually input tags

* Update Comick.kt

* Apply AwkwardPeak's Suggestions

* comma

* transforming serializer on property

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:18:44 +00:00
Smol Ame
f817f7b049
Comico: Remove KO, EN, ZH, move to JA only (#11429)
* Comico: Move from ALL to JA

* Comico: Bump versionCode & update extClass

* Comico: Fresh versionCode
2025-11-22 22:18:44 +00:00
fadiajlil2099
6bf6da4db8
Manhuarm Fixes (#11342)
* Manhuarm.kt

* ManhuarmDto.kt

fixed error bad base64 and chapters not loading for some entries

* Update Manhuarm.kt

Improved nonce extraction by scanning <script> tags for const nonce and adding a fallback value for better reliability.

* Manhuarm.kt

* Update build.gradle

* Update Manhuarm.kt

Fixed popular and latest not showing
Added more robust Nonce regex
Added Custom User Agent feature

* Update ManhuarmDto.kt

* Update build.gradle

Changed BaseUrl

* Update Manhuarm.kt

Changed Url

* Update build.gradle

* removed base64 method since it's not used anymore

* Updated new ocr data fetch

* changed ratelimit
2025-11-22 22:18:44 +00:00
Vacbo
9128e25848
AsuraScans: add auth and premium support (#11339)
* AsuraScans: add auth and premium support

* Fix: satisfy ktlint whitespace

* refactor(asurascans): simplify auth to WebView-only cookie sync

- Remove WebView Activity and AndroidManifest
- Remove manual cookie/token paste preference
- Fix infinite loop crash with header guard
- Fix authentication detection for WebView cookies
- Simplify authInterceptor by removing inverted logic
- Update error messages and preference labels
2025-11-22 22:18:44 +00:00
Creepler13
68ab4021dd
Flamecomics: Fix popular/Search (#11501)
Exclude Novels
2025-11-22 22:18:44 +00:00
Cuong-Tran
2dac04f01b
AllPornComic: Fix popular/latest (#11496)
Fix popular/latest
2025-11-22 22:18:44 +00:00
Gael Pérez
243f4529c6
Update BarManga.kt (#11495)
* Update BarManga.kt

* Update BarManga.kt
2025-11-22 22:18:44 +00:00
Smol Ame
399540fda3
MangaSwat: Update RESTART string (#11491) 2025-11-22 22:18:44 +00:00
anewadventure
8367f924ad
Change URL Manhuarm (#11430) (#11488) 2025-11-22 22:18:44 +00:00
Shirogane
a913bed9af
Hentai2Read: Handle invalid filter states (#11456)
Changes to be committed:
	modified:   src/en/hentai2read/build.gradle
	modified:   src/en/hentai2read/src/eu/kanade/tachiyomi/extension/en/hentai2read/Hentai2Read.kt

 Fixed two critical issues causing internal server errors:
  1. Added safe array access in TagSearchMode filter to prevent ArrayIndexOutOfBoundsException
  2. Added safe array access in UriPartFilter.toUriPart() to prevent NullPointerException

 Both fixes use Kotlin's getOrNull/getOrElse methods to handle invalid filter state values gracefully.
2025-11-22 22:18:44 +00:00
bapeey
25a4651aa5
Atsumaru: Fix popular and latest tabs (#11422)
fix popular and latest
2025-11-22 22:18:44 +00:00
AwkwardPeak7
4c7f5d6a37
Fix potential crash when fetching filters (#11419)
* Comicklive: fix crash on filters

* Kagane: fix crash on filters
2025-11-22 22:18:44 +00:00
Felipe Ávila
9f7b11fc57
Fix GreenShit: remove unnecessary x-client-hash and simplify authentication (#11408)
* corrige autenticação da extensão greenshit removendo x-client-hash desnecessário

- Remove função generateToken() que gerava hash HMAC para o header x-client-hash
- API funciona apenas com token Bearer, x-client-hash não é necessário
- Remove imports não utilizados (Base64, Mac, SecretKeySpec)
- Remove constante SECRET_KEY
- Adiciona campo path ao PageDto para corresponder à resposta da API
- Simplifica pageListRequestMobile para usar apenas header de autorização
- Funcionalidade de login do usuário permanece intacta e automática

* atualiza versionCode da extensão sussyscan e multisrc greenshit

- Incrementa overrideVersionCode de 56 para 57 (sussyscan)
- Incrementa versionId de 2 para 3 (SussyToons.kt)
- Incrementa baseVersionCode de 5 para 6 (greenshit multisrc)

* Reverter versionId

* Remove variave nao utilizada

* Reverte a versaoCode

* remover espaços
2025-11-22 22:18:44 +00:00
are-are-are
84383a1601
Add OTruyen (#11414) 2025-11-22 22:18:44 +00:00
abubaca4
58fda2ef51
[RU]Nudemoon fix page parse (#11411)
Fix page parse
2025-11-22 22:18:44 +00:00
nguyd1
d7444787be
Update CONTRIBUTING.md (#11399)
* update running

* update running and debugging

* update text

* update text

* add alternative

* address comments
2025-11-22 22:18:38 +00:00
AwkwardPeak7
8f13e4185c
MangaFire: search vrf from webview (#11396)
* MangaFire: WebView it out

* bump & stuff
2025-11-22 22:17:52 +00:00
mrtear
399f44d219
Add NyraScans (#11385)
nyrascans
2025-11-22 22:17:52 +00:00
CriosChan
b8f9a38f48
AnimeSama: change domain: .fr to .org (#11380)
AnimeSama: From .fr to .org (closes #11379
)
2025-11-22 22:17:52 +00:00
Gael Pérez
678b988f13
Update BarManga (#11375) 2025-11-22 22:17:52 +00:00
Moha src
d8bef0a1af
Team X: Update chapter list selectors (#11362)
update chapter list selectors and extVersionCode
2025-11-22 22:17:52 +00:00
AwkwardPeak7
b26e4829de
Dynasty: sort by "Best Match" by default & update covers (#11359)
* update tags & covers

* sort by best match by default

* update covers
2025-11-22 22:17:52 +00:00
AwkwardPeak7
05817f38c2
MangaTaro: fix type tag and count views (#11358)
* remove post type from genres

seems to always be manga

* count views

* lint and bump

* add type if others not in tags

* set
2025-11-22 22:17:52 +00:00
AwkwardPeak7
68b70d54d9
add MangaBall (#11344)
* MangaBall

* remove

* suggested changes and more

* remove this

* MangaBall: Fix Korean language code

* change to Locale.ROOT as the pattern isn't language specific

* only throw if filtered
2025-11-22 22:17:52 +00:00
bapeey
9627718a40
MangaCrab: Fix images dont load again (#11347)
* fix bad url

* bump
2025-11-22 22:17:52 +00:00
AwkwardPeak7
cd528cde6a
MangaTaro: handle days in relative date parsing (#11325) 2025-11-22 22:17:52 +00:00
AwkwardPeak7
a2d9686eeb
RawKuma: update chapter list selector (#11324)
they added a download link
2025-11-22 22:17:52 +00:00
Genzales6
3b8be5fd2c
MHScans New domain (#11303) 2025-11-22 22:17:52 +00:00
Kirill Kvit
dc20771476
Flame comics: Update data classes to match updated API responses (#11299)
* Update data classes to match updated API responses

* Update class name to correspond its usage

* Remove fallback string
2025-11-22 22:17:52 +00:00
AwkwardPeak7
db1d8e26f4
Rawkuma: rewrite for new theme (#11270)
* Rawkuma: rewrite for new site layout

* update icon

* filters: use callback to not throw

* deep link

* fix index out of bound

* return if empty

* novels check in deeplink

* Add `@Synchronized` to `getNonce`
2025-11-22 22:17:52 +00:00
Fioren
9f720a3488
add mehentai (#11298)
* add mehentai

* fix path
2025-11-22 22:17:52 +00:00
manti
f019c2a273
Add Gangan Online (#11280)
* add gangan

* refactor

* rm
2025-11-22 22:17:52 +00:00
AwkwardPeak7
5f6e00499a
MangaTaro: unescape title & get thumbnail directly (#11273)
* MangaTaro: use `_embed` on /wp-json/ endpoint

- saves a network call to get thumbnail

* unescape entities
2025-11-22 22:17:52 +00:00
CriosChan
f5429b887f
Add MangaNova (#11260)
* MangaNova: V1 (Closes #11259)

* MangaNova: Bad isNsfw in build.gradle

* MangaNova: Changes asked by AwkwardPeak7

* MangaNova: Spelling Mistake
2025-11-22 22:17:52 +00:00
CriosChan
6071f598f4
BigSolo : Fix extension #11226 (#11233)
* BigSolo: Revert changes of #11156

* BigSolo: Fix for recent backend changes (Closes #11226) + Open link in app

* BigSolo: Change name to "BigSolo" -> override id

* BigSolo: Use enpoint "/data/series/{slug}" + optimization

* BigSolo: Add www subdomain to Manifest

* BigSolo: data class to class + remove unused variables in serializable

* BigSolo: Remove unused variable

* BigSolo: Changes asked by vetleledaal

* BigSolo: Changes asked by AwkwardPeak7
2025-11-22 22:17:52 +00:00
bapeey
eab806d5b1
IkigaiMangas: Fix pages not found (#11269)
add nsfw cookie only when needed
2025-11-22 22:17:52 +00:00
manti
cf7446489d
Fix MagazinePocket (#11266)
* fix MagazinePocket

* Update src/ja/magazinepocket/src/eu/kanade/tachiyomi/extension/ja/magazinepocket/MagazinePocket.kt
2025-11-22 22:17:52 +00:00
AlphaBoom
546696c0d2
Nihonkuni:Resolve ad break page. (#11261)
* Nihonkuni:Resolve ad break page.

* Just simply use cookie interceptor.
2025-11-22 22:17:52 +00:00
CriosChan
9b206fc092
Add ScanR (#11245)
* ScanR: V1 closes (#11244)

* ScanR: Changes asked by AwkwardPeak7 + NSFW in manga name
2025-11-22 22:17:52 +00:00
AwkwardPeak7
afbbe6991f
Kagane: use site's chapter numbers & cache filters in network cache (#11248)
* Kagane: chapter number from site

* Kagane: fetch and cache filters in network cache
2025-11-22 22:17:49 +00:00
AwkwardPeak7
2b394c8c38
MangaTaro: fix chapter name (#11246) 2025-11-22 22:17:49 +00:00
AwkwardPeak7
5a004d08f5
Cubari: simplify url handling and search (#11216)
* migrate to keiyoushi.utils.parseAs

* Refactor: Simplify URL handling

- Pass URLs directly to search instead of pre-parsing in CubariUrlActivity
- Remove custom `cubari:` prefix and parser
- Handle direct URL searching in the main source file
- Add support for Gist URLs
- Remove redundant intent filters from AndroidManifest

* bump and fix useragent

* fix url

* update useragent

* trailing comma
2025-11-22 22:17:49 +00:00
Aurel
775d39331e
Add Manga-Corporation extension (#11237) 2025-11-22 22:17:49 +00:00
bapeey
77b4ea1261
Atsumaru: Update status values (#11236)
update status values
2025-11-22 22:17:49 +00:00
Cuong-Tran
0aca7c467c
Kagane: Fix image loading, add more filters, fix NSFW (#11224)
* Kagane: Fix image loading

* Kagane: Add content-rating filter

Close #11158

* Kagane: Add Sources filter

* Kagane: Add Genres/Tags filter

* Kagane: Add Scanlations

* refactor

* fetching image URL from challenge

* fetching image URL from challenge

* enable scanlations for browsing

* fetch genres, tags & sources list

* Using `Filter.Sort`

---------

Co-authored-by: kana-shii <79055104+kana-shii@users.noreply.github.com>
2025-11-22 22:17:49 +00:00
AwkwardPeak7
b4b8bbe748
MangaFire: prevent crash on main thread due to network issues (#11221)
* webview

* fix illegalstateexception

* fix

* lint

* timeout
2025-11-22 22:17:49 +00:00
Riiise
7e5b58bb5c
Fixed YakshaComics 403 error (#11220)
fixed YakshaComics 403 error
2025-11-22 22:17:49 +00:00
Cuong-Tran
67e224826a
fix(emperor): update domain (#11218) 2025-11-22 22:17:49 +00:00
Luqman
59f72d823b
Kiryuu: fix thumbnail issue (#11217) 2025-11-22 22:17:49 +00:00
Genzales6
3aa18a35a2
InmortalScan / Raijin Scans Url Updates (#11213)
* InmortalScan: Update Domain

* RaijinScans: Update Domain
2025-11-22 22:17:49 +00:00
Vetle Ledaal
975216f40d
Remove Shadow Mangas, TecnoProjects (#11208)
* Remove Shadow Mangas

* Remove TecnoProjects
2025-11-22 22:17:49 +00:00
marioplus
7d754490b8
fix(bakamh): fix unable to load chapter (#11180)
* fix(bakamh): fix unable to load chapter

* refactor(bakamh): optimize chapter list parsing logic

- Consolidate chapter selectors into single method
2025-11-22 22:17:49 +00:00
mozzaru
284d807f53
komikcast: Update Domain (#11195) 2025-11-22 22:17:48 +00:00
Vetle Ledaal
a98f720ff3
Remove dead sources (#11188)
* Remove Kanjiku

* Remove DAYcomics.me

* Remove Catharsis Fantasy
2025-11-22 22:17:48 +00:00
manti
89f33e0106
Add TakeComic / Remove Web Comic Ganma Plus & Web Comic Ganma & multisrc (#11183)
* add storiadash

* remove and add takecomic

* api and refactor

* apiUrl
2025-11-22 22:17:48 +00:00
marioplus
3af84ded97
fix(YellowNote): unable to load next page (#11181) 2025-11-22 22:17:48 +00:00
AwkwardPeak7
3968208d9c
add Mangataro (#11177)
* Mangataro

* Refactor: Simplify search payload creation

- Use a custom serializer for search filter parameters.
- Remove redundant `.toJsonString()` calls for each filter.
- Update filter classes to use appropriate data types (Int, Int?, String?) instead of just Strings, improving type safety.
- Change `firstInstanceOrNull` to `firstInstance` for non-nullable filters.

* Refactor: Move deeplink handler

Move the deeplink handler function to a more logical position after the search parsing logic.

* MangaTaro: Implement new search method

- Add a new text search method that uses a different API endpoint. This provides more relevant results but ignores filters.
- Add a filter option to toggle between the new search and the old filter-based search.
- Exclude novels from appearing in search results and manga details.
2025-11-22 22:17:48 +00:00
Fioren
fe130d5aa8
Webtoonxyz: Fix thumbnail blurred image (#11167)
fix thumb
2025-11-22 22:17:48 +00:00
Cuong-Tran
8b0be67685
Batoto: Custom regex for cleaning title & fix duplicate manga (#11164)
* Batoto: config custom regex to be removed from title

* fix(BatoTo): add original (uncleaned) title to description

* verify regex

* add real-time validation for custom regex input & update summary on change

* Also clean title while browsing/searching

* Batoto: Fix duplicate manga due to name changed

Close #11037
2025-11-22 22:17:48 +00:00
Cuong-Tran
6824183cf1
Hiperdex: add regex to clean title (#11163)
* feat(Hiperdex): add preferences to remove title regex

* feat(Hiperdex): add preferences to remove title regex

* add original title to description

* make companion

* custom regex verification

* verify regex

* Revert "Revert "switch to checkbox""

This reverts commit 491fd17282fad18a04b3ff1570784954fde2703f.

* add real-time validation for custom regex input & update summary on change

* Fix version

* Also clean title while browsing/searching
2025-11-22 22:17:48 +00:00
Cuong-Tran
ad03299e49
MangaDistrict: custom title regex (#11161)
* feat(MangaDistrict): only set chapter date got from page-list if it still has NEW tag

* feat(MangaDistrict): Add custom title regex & remove the cleaning from browsing

* verify regex

* switch to checkbox

* refactor: remove redundant methods for popular and latest manga elements

* update summary after edit

* validate custom regex on edit

* refactor: simplify regex validation logic

* refactor: improve null safety in afterTextChanged method

* Revert "refactor: remove redundant methods for popular and latest manga elements"

This reverts commit dfc0e643aaa5330d48edf07204db10dd29a24c35.

* Add back cleaning title for browsing/searching

* clear date from preference

* small refactor
2025-11-22 22:17:48 +00:00
Emixam
fa899de7d9
Add Les Poroiniens (#11156)
* Add ScanR multi-source

Adds the ScanR multi-source template, adapting code from the BigSolo source

* Migrate BigSolo to ScanR multi-source

Uses the ScanR multi-source template for the extension

* Add Les Poroiniens source

Adds the Les Poroiniens source, which uses the ScanR multisource.

* Use named parameters
2025-11-22 22:17:48 +00:00
AwkwardPeak7
80a5052273
add QToon (#11131)
* basic encrypted request done

* implement everything except search/filters

* search & filters

* remove logs

* Add home page sections

- Add home page sections as a filter.
- This filter does not work with other filters.

* Fix filters

- Remove duplicate filters
- Remove commented out code

* QToon: Fix opening in webview

- Remove logging
- Fix manga and chapter URLs for non-English languages

* QToon: Add URL intent filter

- Add URL intent filter for manga details and reader pages.
- Handle URL intents to open manga directly in the app.

* set details when browsing

* update icon and www. domain in manifest

* append
2025-11-22 22:17:48 +00:00
SupremeDeity
141b80aa19
Add WeebDex (#11113)
* Add WeebDex

* fixes and add scanlator info

* add placeholder icons

* Refactoring with reviewed changes

* minor refactoring to fix build

* Url and Chapter title fixes

fix: Add manga URL correctly.
feat: Improve chapter title format by including volume and chapter numbers, and a "Oneshot" fallback.
fix: An issue where oneshot chapters show Chapter null

* Remove extraneous json field
2025-11-22 22:17:48 +00:00
SupremeDeity
99dde3ca4d
Add Zazamanga (#11166)
* Add Zazamanga

* Add icons and do fixes
2025-11-22 22:17:48 +00:00
are-are-are
6cf479516f
Update some domain (#11152)
* HentaiVNPlus: Update domain

* DocTruyen3Q: Update domain

* TruyenTranh3Q: Update domain

* TopTruyen: Update domain

* VlogTruyen: Update domain
2025-11-22 22:17:48 +00:00
wbmins
551f9a032f
When the cover is damaged, refresh to get the latest cover (#11146)
* Update cover

* Bump the extension version
2025-11-22 22:17:48 +00:00
manti
8c34cc1b5b
Add ClipStudioReader (lib) (#11120)
* add clipstudioreader and drecomics

* add firecross

* webtoon DreComics and try find key in url

* fixes

* drecomics search and filters

* firecross search/filters, csr epub viewer support

* migrate to lib and xml parser

* api

* cleanup and dependency
2025-11-22 22:17:48 +00:00
CriosChan
b6f6b46a4f
Luscious: fix 403 error + Open URL in app (closes #10924) (#11119)
* Luscious: fix 403 error + Open URL in app (closes #10924)

* Luscious: Preserve app provided useragent
2025-11-22 22:17:48 +00:00
Hiirbaf
156f67ad7e
LectorTmo: include book type to genre (#11097)
* LectorTmo: include book type to genre

Refactor genre extraction to include book type

* Update src/es/lectortmo/src/eu/kanade/tachiyomi/extension/es/lectortmo/LectorTmo.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Increment version code

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:17:48 +00:00
Romain
3404731442
Fix Scanmanga (#11149)
Partial revert of "Remove some dead sources (#11133)"

This revert only the source Scan-Manga, who still exists but is blocked outside of France.

This reverts commit 7c8903e84c3a080f77b1401f531e65f70eb6c1f7.
2025-11-22 22:17:48 +00:00
Luqman
bacd296e4e
Manga Demon: filter ads, loosen & separate thumbnail ratelimit (#11144)
* Manga Demon: filter ads, loosen ratelimit

* Manga Demon: separate thumbnail ratelimit
2025-11-22 22:17:48 +00:00
Smol Ame
adb0b65b64
Remove ManhuaManhwa (#11140) 2025-11-22 22:17:48 +00:00
Smol Ame
3cb189632d
Remove Aurora Scans (EN) (#11134) 2025-11-22 22:17:48 +00:00
Vetle Ledaal
d2878c5670
Remove some dead sources (#11133)
* Remove Manhwafull

* Remove Mangahasu

* Remove MangaScans

* Remove MyShojo

* Remove Iris Scans

* Remove Constellar Scans

* Remove King of Scans

* Remove 1st-Kiss Manga.net

* Remove Banana Manga

* Remove Todaymic

* Remove Comicsekai

* Remove KataKomik

* Remove Komik Lovers

* Remove Mangakyo

* Remove MangaYu

* Remove Tenshi.id

* Remove TukangKomik

* Remove YuraManga

* Remove Pojok Manga

* Remove Scan-Manga

* Remove Valkyrie Scan

* Remove Starbound Scans

* Remove Manga Rock Team

* Remove Tres Daos Net
2025-11-22 22:17:36 +00:00
Genzales6
e92d5d1819
MHScans & BiblioPanda update url (#11132)
* MHScans / BiblioPanda Fix url

* MH changed domain last hour (1)

* MH changed domain last hour (2)

build.gradle
2025-11-22 22:16:26 +00:00
TheKingTermux
7a61751a50
Doujindesu error 404 HotFix (#11128)
* Sometimes i found another problem

- Fixed Author, Group and Series Filters only 1 active (many intentionally or unintentionally enter input in several Filters which makes the results Null)
- Fixed all HTML Tags such as &bnsp;, &gt; and &lt; and so on in the description
- Fixed descriptions for Manhwa that were cut off due to different logic

* Fix DoujinDesu `404` HOTFIX

* Remove unused pattern

* Remove Log

* Revert Description

Reverted to May Version Description and slightly make better
2025-11-22 22:16:26 +00:00
mrtear
d1bed69ada
Add KaizenScan (#11126)
kaizenscan
2025-11-22 22:16:26 +00:00
mrtear
652a56fdc6
Add EvaScans (#11125)
evascans
2025-11-22 22:16:26 +00:00
nicki
9c74df6069
MangaPlus: use alternative api endpoint that contains all latest updates (#11122)
use non-banner api endpoint that hopefully contains all updates
2025-11-22 22:16:26 +00:00
wbmins
007d231fa7
E-Hentai: use backup imageurl on error (#11108)
* add "Reload broken image" source as backup

* bump the extension version
2025-11-22 22:16:26 +00:00
bapeey
a78b2624da
IkigaiMangas: Add chapter title and update default base url (#11103)
add chapter title
2025-11-22 22:16:26 +00:00
bapeey
d7241034bc
ManhwaWeb: Fix incorrect slug (#11102)
fix slug
2025-11-22 22:16:26 +00:00
are-are-are
16073b38cb
GocTruyenTranh: Fix Descripsion, Fix Thumbnail (#11101)
GocTruyenTranh: Fix descripsion, Fix Thumbnail
2025-11-22 22:16:25 +00:00
Trevor Paley
880b04047e
K Manga: implement "latest" support (#11100)
K-Manga: implement "latest" support
2025-11-22 22:16:25 +00:00
TheKingTermux
e3449dfb65
Change some Domain (#11091)
Change some URL

Change url from:
- ManhwaLand.mom
- ManhwaDesu
2025-11-22 22:16:25 +00:00
AwkwardPeak7
89c380c808
Comick (unoriginal) (#11074)
* Comick (Unoriginal)

* icon

* factory languages

* no need for custom disk cache

* unused
2025-11-22 22:16:25 +00:00
haozihong
cbaf26bf4d
Manhuaren: Allow setting user ID and token (#11042)
Allow setting user ID and token in preferences for users who have trouble automatically fetching them.
Also, improve the error message when the token expires.
2025-11-22 22:16:25 +00:00
manti
49a970fde8
ComicMeteor/Kiraboshi: redesign (#11084)
fix and rename to kiraboshi
2025-11-22 22:16:25 +00:00
AwkwardPeak7
145dc251e6
MangaFire: fix mobile useragent requirment & trust all certs (#11065)
* MangaFire: load webResource via okhttp

removes mobile chrome ua dependency

* trust all certs

* convert vrf script to kotlin

taken from: KotatsuApp/kotatsu-parsers

* bump
2025-11-22 22:16:25 +00:00
Luqman
979ae7f53f
Ikiru: fix search & chapter list (#11060) 2025-11-22 22:16:25 +00:00
manti
f85c52aa38
Add RestScans (#11055)
add restscans
2025-11-22 22:16:25 +00:00
Vetle Ledaal
039c241d33
Bato.to: fix auto mirror w/o restart, update mirror list (#11054)
* Bato.to: always call `getMirrorPref()` in `baseUrl`

You no longer need to restart after changing to the 'auto' mirror.

* Bato.to: update mirror list

* Bato.to: bump version
2025-11-22 22:16:25 +00:00
Useles5
3afb33fe4a
Fix InfraFandub extension (#11035)
* Fix InfraFandub extension

* Apply review suggestions

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:16:25 +00:00
abubaca4
fc9dc6c4f2
[RU]Nudemoon chapter list fix and genre list update (#11033)
* Chapter list multiple page fix

* Genre list update

* Update src/ru/nudemoon/src/eu/kanade/tachiyomi/extension/ru/nudemoon/Nudemoon.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* One more absUrl

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:16:25 +00:00
dngonz
f5bc644071
Kagane: Fix search and chapters index (#11011)
* fix search and chapters index

* bump

* Update src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:16:25 +00:00
Emixsan
b838d8b34b
Add BigSolo (#10992)
* Adds BigSolo source extension

* Improves BigSolo extension

Improves the BigSolo extension by introducing DTOs for data handling.

Enhances error handling and streamlines the parsing of data from JSON responses.

* Refactors BigSolo extension

Migrates BigSolo extension from ParsedHttpSource to HttpSource.

Utilizes keiyoushi.utils.parseAs for JSON parsing.

Implements in-memory caching for series data to reduce redundant calls.

Passes the search query as URL fragment.

* Simplifies DTO and uses non-null assertions
2025-11-22 22:16:25 +00:00
Hemal Kothapalli
24d5d8920b
Fix sana scans search and downloading (#11024)
* Sana Scans: fix search filtering and chapter page order

- normalize search queries in Sana Scans so catalog results filter properly
- sort chapter images using the site-provided order field in the shared Iken multisrc

* Sana Scans: additional changes to meet checklist

* implement changes from reviewer

* fix iken base version

* fix errors in autometed testing
2025-11-22 22:16:25 +00:00
bapeey
4007461062
Atsumaru: Fix covers (#11046)
fix covers
2025-11-22 22:16:25 +00:00
Chentao Ye
9f9cf36a20
fix yanmaga not show chapter list (#11039)
fix yanmaga1.4.2 not show chapter list
2025-11-22 22:16:25 +00:00
are-are-are
5997e5507e
GocTruyenTranh: Update domain & update parseDate, parseStatus (#11029)
GocTruyenTranh: Update domain & update parseDate, parseStatus, & fix mangaDetailsParse
2025-11-22 22:16:25 +00:00
manti
9aabbfdd82
Comic Growl: migrate to multisrc (#11015)
migrate to multisrc
2025-11-22 22:16:25 +00:00
manti
83731ebae6
ChampionCross/MangaCross: theme change (#11014)
fix championcross
2025-11-22 22:16:25 +00:00
are-are-are
9b874ae720
Batoto: Fix bug setting mirror to auto (#11012)
Batoto: Fix bug baseUrl = "Auto"
2025-11-22 22:16:25 +00:00
Genzales6
b38f8bd0f5
CatharsisWorld Add Url Editor (#10969)
* CatharsisWorld Add Url Editor

* Apply suggestion from @AwkwardPeak7

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Apply suggestion from @AwkwardPeak7

chang

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Change sugi

* cathasis over ride

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
2025-11-22 22:16:25 +00:00
dngonz
4e8f7db34f
Kagane: page count check (#11002)
* fix small condition

* bump
2025-11-22 22:16:25 +00:00
AwkwardPeak7
a493a38e6e
fix inspector crash 2025-11-22 22:16:25 +00:00
AwkwardPeak7
7377d6427b
MangaFire: get/generate vrf for ajax calls (#10988)
* fix page list
- remove ajax call from chapter list
- use webview to get vrf

* fix query search

- get vrf from webview

* bump

* trigger search on script load, reduce wait time

* vrf script by user podimium on Discord

Co-authored-by: Trung0246 <11626920+Trung0246@users.noreply.github.com>

* use vrf script for search as webview isn't reliable for that

* remove unused

---------

Co-authored-by: Trung0246 <11626920+Trung0246@users.noreply.github.com>
2025-11-22 22:16:25 +00:00
dngonz
4ac7d3559c
Kagane: Fix MissingFieldException Error (#10981)
* fix dto and small fixes

* bump

* fix authors

* remove unused code and fix review comment

* fix lint

* fix lintx2 :(
2025-11-22 22:16:25 +00:00
manti
ccbde23c1f
ComiciViewer: add multisrc Comici Viewer (#10970)
* multisrc comici

* fixes and filters

* fix parameters

* fixes and sources
2025-11-22 22:16:25 +00:00
Smol Ame
9282d65dd8
Vortex Scans: Fix HTTP 500 error appearing on certain entries (#10997)
* Vortex Scans: Bump versionCode

* Vortex Scans: Add fetch override

* Vortex Scans: Remove unnecessary fetchMangaDetails override

---------

Co-authored-by: AwkwardPeak7 <48650614+awkwardpeak7@users.noreply.github.com>
2025-11-22 22:16:25 +00:00
Luqman
7112ffb937
Ikiru: update domain (#10964)
* Ikiru: update domain

also increase random number to decrease the odds of blocking certain number

* Update src/id/mangatale/src/eu/kanade/tachiyomi/extension/id/mangatale/Ikiru.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:16:25 +00:00
tanaka-shizuku3
b0bfe86ca2
Add Creative Comic Collection (#10739)
* Add Creative Comic

* Add login

* Make getToken() non null

* Use String.parseAs
2025-11-22 22:16:25 +00:00
Yakoo
38732723a6
AnimeSama - Fix manga with trailing spaces (#10986)
* Update build.gradle

* Update AnimeSama.kt Fix mangas with trailing space

Fix mangas with trailing spaces that brakes listing and downloads (ex Tower of God)
2025-11-22 22:16:25 +00:00
dngonz
fe5d96f4c5
MangaBox: Fix search selector (#10982)
fix search selector
2025-11-22 22:16:25 +00:00
Riiise
914c609279
Add YakshaComics Extension (#10965)
* Add YakshaComics Extension

* changed isNsfw to false/set overrideVersionCode to 0
2025-11-22 22:16:25 +00:00
Justin COLLON
805fa1f631
Japscan: Update domain (#10961)
* Update Japscan new domain

* Increment version code from 53 to 54
2025-11-22 22:16:25 +00:00
Julio Reigen
93f2816776
RezoScans: Update domain and theme (#10937)
* fix: RezoScans

* fix: lint

* Update version id

* update versionId
2025-11-22 22:16:25 +00:00
Themis
322ef36ab0
Rawkuma: Update Domain (Temp Fix) (#10923)
* Update Domain & Version Bump

* Update Domain
2025-11-22 22:16:25 +00:00
Trevor Paley
a8daf1f8ec
Add BookWalker Global (#10846)
* Add BookWalker Global extension

* Add option to configure opening login page in webview

* BookWalker: clean up code, remove PREF_TRY_OPEN_LOGIN_WEBVIEW
2025-11-22 22:16:24 +00:00
Prem Kumar
08ead06187
Rage Scans: Exclude Paid chapters (#10945)
* Exclude locked chapters for Rage Scans

* lint
2025-11-22 22:16:24 +00:00
are-are-are
0e218bbfac
TruyenTranh3Q: Update Icon, Update domain. Fix mangaDetailsParse, Fix parseDate (#10933)
* Update Icon

* Update domain, Fix Status, Fix ParseDate

* Bump Version

* = ̄ω ̄=
2025-11-22 22:16:24 +00:00
Hasan
07c8fd1080
Fix Uzay Manga: HTTP 404 on reader (#10921)
Fix: HTTP 404 on reader

Updated the CDN URL and image path construction.
2025-11-22 22:16:24 +00:00
Hasan
f26545a8e8
Fix Elder Manga: HTTP 404 on reader (#10920)
Fix: HTTP 404 on reader

Updated the CDN URL and image path construction.
2025-11-22 22:16:24 +00:00
zhongfly
85d3008fb8
Zaimanhua: Support search by ID & proactively acquire token (#10916)
* Zaimanhua: Support search by ID

Add the ability to directly open a manga by searching for its ID.
A new filter option is added to allow searching for numbers instead of jumping to the manga page.

* Zaimanhua: proactively acquire token

Attempt login when credentials are available instead of waiting for chapter fetch failure to refresh.
2025-11-22 22:16:24 +00:00
dngonz
f2b1f92502
Kagane: FIx search and added data saver (#10904)
* fix search

* fix open in webview

* add data saver feature

* bump

* fix popular and latest filter
2025-11-22 22:16:24 +00:00
are-are-are
ada0565967
VlogTruyen: Update domain (#10901) 2025-11-22 22:16:24 +00:00
are-are-are
2bc7dad385
HentaiVNPlus: Update domain (#10900)
HentaiVNPlus update domain
2025-11-22 22:16:24 +00:00
are-are-are
a4c4090301
GocTruyenTranhVui: Allows getting token for login in webview (#10887)
* GocTruyenTranhVui: Allows to get token. Support login

Instead of always saving tokens in code.

* Lines 118 to 143 move to getToken(), and not use postDelayed

* Support enter token manually

Support Suwayomi users who can't log in can still enter token manually
2025-11-22 22:16:24 +00:00
Chopper
5ef2e36997
LumosKomik: Update domain (#10891)
Update domain
2025-11-22 22:16:24 +00:00
Luqman
92a0ada358
Keyoapp: tweak selector; Asmodeus Scans: filter novel,fix info (#10870)
* Asmodeus Scans: filter novel, add/fix genres

* Keyoapp: fix n tweak genre,type selector

- previous type selector was mistakenly named genre
- make genre selector val
- tweak the genre selector to exclude manga status from genre

* fix

* tweak description
2025-11-22 22:15:59 +00:00
Vetle Ledaal
ab4b1e2276
Remove unused OtakuSanctuary multisrc (#10862) 2025-11-22 22:15:59 +00:00
are-are-are
107e060ddc
Madara: Add wordset for parseChapterDate (#10567)
* Madara: Add wordset for parseChapterDate

* Revert bump version

* Unrevert bump version
2025-11-22 22:15:59 +00:00
are-are-are
e114e5a257
LxManga: Update headers (#10886) 2025-11-22 22:15:59 +00:00
Luqman
0ac4b785aa
Ikiru: tweak chapter list (#10879) 2025-11-22 22:15:59 +00:00
Luqman
30226abee7
Ngomik: rename + (unoriginal) (#10872) 2025-11-22 22:15:59 +00:00
dngonz
22f0293165
Webcomics: Fix no results found (#10871)
fix extension
2025-11-22 22:15:59 +00:00
Chopper
a7cfba60a2
Manhuarm: Fix dialogs (#10866)
Fix dialogs
2025-11-22 22:15:59 +00:00
keegang
50ae4f3f06
Nekopost - Search Error "HTTP 404" Fix (#10833)
* fiex url and request model

* fix request model

* it's working so i made a check point

* everything working fine I've tested it

* rename PagingRequest to PagingInfo, remove default data from model

* use @SerialName for ProjectDate

* use filterNot instead of filter {!...}

* refactor: optimize code

- Use lazy initialization for SimpleDateFormat to avoid repeated instantiation
- Simplify property declarations with type inference
- Replace verbose null checks with Elvis operators and safe calls
- Eliminate unnecessary intermediate variables
- Combine existingProject.add() with map operation for better efficiency
- Inline object creation in SearchRequest constructor
- Use run block for cleaner null handling in popularMangaParse
- Reduce string concatenation by caching basePath in pageListParse
- Remove redundant status checks and variable assignments

tested BUILD SUCCESSFUL
tested fonctionality in mihon
2025-11-22 22:15:59 +00:00
C0NTR1BUT0R
2928fc45a6
Update Japscan extension to improve chapter URL extraction after the new obfuscation techniques (#10807)
* Update Japscan extension to improve chapter URL extraction and error handling

* fix lint
2025-11-22 22:15:59 +00:00
ipcjs
6a29aa1afd
fix(nhentaicom): fix image and language loading errors. (#10412)
* fix(nhentaicom): lang code error

* fix(nhentaicom): token parse error

* chore(nhentaicom): update version code

* fix(nhentaicom): fix 403 error

* fix(nhentaicom): Fix HentaiHand image loading when logged in

The token interceptor was incorrectly trying to add a token to image requests, which are not on the source's domain, causing image loading to fail when logged in.

* fix(nhentai): fix null description

* wip: restore code from the crashed rog laptop.

* refactor: use @Serializable

* fix: parse error

* wip: use keiyoushi.utils.parseAs
2025-11-22 22:15:59 +00:00
Luqman
d4d2785853
Komik Cast: update domain (#10880) 2025-11-22 22:15:59 +00:00
CriosChan
0c8a27b820
AnimeSama: Switch to New Endpoint and Direct AnimeSama Link Integration (#10802)
* Open URL in apps + new way to get all chapters

* AnimeSama: Update extVersionCode

* AnimeSama: Changes requested by vetleledaal

* AnimeSama: Fix special chapters that could not be opened
2025-11-22 22:15:59 +00:00
Luqman
3b0eb9a789
Iken: update popular manga; Aurora Scans: fix browse (#10811) 2025-11-22 22:15:59 +00:00
lord-ne
562983bca3
[Iken] Add lock prefix only if not accessible (#10796)
* Add lock prefix only if not accessible

* Update baseVersionCode

* Update preference text to match function

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:15:59 +00:00
are-are-are
23e386a7e5
Remove OtakuSanctuary (#10861)
remove dead source
2025-11-22 22:15:59 +00:00
are-are-are
00046adbdc
Remove MimiHentai (#10860)
remove dead source
2025-11-22 22:15:59 +00:00
dngonz
9d05af7e2d
Tojimangas: Update domain (#10857)
update url
2025-11-22 22:15:59 +00:00
Luqman
948decf018
MangaGeko: Fix Chapter numbers when logged in (#10854) 2025-11-22 22:15:59 +00:00
dngonz
e533814cc9
Kagane: Fix chapters error and search (#10819)
* fix extension

* modify url

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:15:59 +00:00
Luqman
e7e9bc349d
Team Lanh Lung: change domain (#10852) 2025-11-22 22:15:59 +00:00
Luqman
1b0b8e103f
Ken Scans: fix browse, site changing theme (#10851)
* Ken Scans: fix browse, site changing theme

* Update src/en/kenscans/src/eu/kanade/tachiyomi/extension/en/kenscans/KenScans.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* Update src/en/kenscans/src/eu/kanade/tachiyomi/extension/en/kenscans/KenScans.kt

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

* cleaning import

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
2025-11-22 22:15:59 +00:00
Cuong-Tran
7071d503a7
MyReadingManga: Fix potential memory leaks and exceptions (#10848) 2025-11-22 22:15:59 +00:00
Trevor Paley
7b44fec7db
Mangamo: Fix URLs for manga with question marks in title (#10847)
* Mangamo: fix URLs for manga with question marks in title

* Mangamo: bump version
2025-11-22 22:15:59 +00:00
bapeey
dd6f475a01
AllManga: Allow nullable image url (#10845)
* nullable imageUrl

* bump
2025-11-22 22:15:59 +00:00
bapeey
233df400f0
MangaCrab: Fix images not loading (#10844)
* fix images not loading

* apply suggestion
2025-11-22 22:15:59 +00:00
Luqman
eb6fe726df
Koreli Scans: fix browse, site changing theme (#10815) 2025-11-22 22:15:59 +00:00
Luqman
0aac33c331
MangaGezgini: update domain (#10809) 2025-11-22 22:15:59 +00:00
Genzales6
dbb77d4a12
Catharsis World URL Updated (#10801)
Closes #10794
2025-11-22 22:15:59 +00:00
solkaz
577ac38ac7
Remove EnryuManga as source (#10840)
Remove EnryuManga

Closes #10771
2025-11-22 22:15:59 +00:00
bapeey
7f05502c60
Remove HadesNoFansubHentai (#10839)
remove dead source
2025-11-22 22:15:59 +00:00
bapeey
11e61df10a
HadesNoFansub: Update domain (#10838)
update domain
2025-11-22 22:15:59 +00:00
bapeey
f945d11a8f
NineManga: Update EN domain (#10837)
update EN domain
2025-11-22 22:15:59 +00:00
bapeey
e4cb04df6c
DoujinHentai: Fix HTTP 404 (#10836)
fix 404
2025-11-22 22:15:58 +00:00
bapeey
6968ba5e19
BarManga: Fix images not loading (#10835)
fix images not loading
2025-11-22 22:15:58 +00:00
bapeey
97ab81e855
TraduccionesMoonlight: Fix images not loading (#10834)
fix images not loading
2025-11-22 22:15:58 +00:00
Luqman
64d08b63ee
Arcanescans: fix browse, site changing theme (#10814)
manga url same as previous theme
2025-11-22 22:15:58 +00:00
Luqman
258874dc70
MangaGeko: fix some synopsis issue (#10813) 2025-11-22 22:15:58 +00:00
1071 changed files with 19840 additions and 6581 deletions

View File

@ -10,31 +10,54 @@ or fixing it directly by submitting a Pull Request.
## Table of Contents
1. [Prerequisites](#prerequisites)
1. [Tools](#tools)
2. [Cloning the repository](#cloning-the-repository)
2. [Getting help](#getting-help)
3. [Writing an extension](#writing-an-extension)
1. [Setting up a new Gradle module](#setting-up-a-new-gradle-module)
2. [Core dependencies](#core-dependencies)
3. [Extension main class](#extension-main-class)
4. [Extension call flow](#extension-call-flow)
5. [Misc notes](#misc-notes)
6. [Advanced extension features](#advanced-extension-features)
4. [Multi-source themes](#multi-source-themes)
1. [The directory structure](#the-directory-structure)
2. [Development workflow](#development-workflow)
3. [Scaffolding overrides](#scaffolding-overrides)
4. [Additional Notes](#additional-notes)
5. [Running](#running)
6. [Debugging](#debugging)
1. [Android Debugger](#android-debugger)
2. [Logs](#logs)
3. [Inspecting network calls](#inspecting-network-calls)
4. [Using external network inspecting tools](#using-external-network-inspecting-tools)
7. [Building](#building)
8. [Submitting the changes](#submitting-the-changes)
1. [Pull Request checklist](#pull-request-checklist)
- [Contributing](#contributing)
- [Table of Contents](#table-of-contents)
- [Prerequisites](#prerequisites)
- [Tools](#tools)
- [Cloning the repository](#cloning-the-repository)
- [Getting help](#getting-help)
- [Writing an extension](#writing-an-extension)
- [Setting up a new Gradle module](#setting-up-a-new-gradle-module)
- [Loading a subset of Gradle modules](#loading-a-subset-of-gradle-modules)
- [Extension file structure](#extension-file-structure)
- [AndroidManifest.xml (optional)](#androidmanifestxml-optional)
- [build.gradle](#buildgradle)
- [Core dependencies](#core-dependencies)
- [Extension API](#extension-api)
- [DataImage library](#dataimage-library)
- [i18n library](#i18n-library)
- [Additional dependencies](#additional-dependencies)
- [Extension main class](#extension-main-class)
- [Main class key variables](#main-class-key-variables)
- [Extension call flow](#extension-call-flow)
- [Popular Manga](#popular-manga)
- [Latest Manga](#latest-manga)
- [Manga Search](#manga-search)
- [Filters](#filters)
- [Manga Details](#manga-details)
- [Chapter](#chapter)
- [Chapter Pages](#chapter-pages)
- [Misc notes](#misc-notes)
- [Advanced Extension features](#advanced-extension-features)
- [URL intent filter](#url-intent-filter)
- [Update strategy](#update-strategy)
- [Renaming existing sources](#renaming-existing-sources)
- [Multi-source themes](#multi-source-themes)
- [The directory structure](#the-directory-structure)
- [Development workflow](#development-workflow)
- [Scaffolding overrides](#scaffolding-overrides)
- [Additional Notes](#additional-notes)
- [Running](#running)
- [Debugging](#debugging)
- [Android Debugger](#android-debugger)
- [Logs](#logs)
- [Inspecting network calls](#inspecting-network-calls)
- [Using external network inspecting tools](#using-external-network-inspecting-tools)
- [Setup your proxy server](#setup-your-proxy-server)
- [OkHttp proxy setup](#okhttp-proxy-setup)
- [Building](#building)
- [Submitting the changes](#submitting-the-changes)
- [Pull Request checklist](#pull-request-checklist)
## Prerequisites
@ -692,43 +715,46 @@ with open(f"{package}/src/{source}.kt", "w") as f:
## Running
To make local development more convenient, you can use the following run configuration to launch
Tachiyomi directly at the Browse panel:
For local development, use the following run configuration to launch the app directly into the Browse panel.
![](https://i.imgur.com/STy0UFY.png)
![](https://i.imgur.com/6s2dvax.png)
If you're running a Preview or debug build of Tachiyomi:
Copy the following into `Launch Flags` for the Debug build of Mihon:
```
-W -S -n eu.kanade.tachiyomi.debug/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
-W -S -n app.mihon.dev/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
```
And for a release build of Tachiyomi:
For other builds, replace `app.mihon.dev` with the corresponding package IDs:
- Release build: `app.mihon`
- Preview build: `app.mihon.debug`
```
-W -S -n eu.kanade.tachiyomi/eu.kanade.tachiyomi.ui.main.MainActivity -a eu.kanade.tachiyomi.SHOW_CATALOGUES
```
If the extension builds and runs successfully then the code changes should be ready to test in your local app.
> [!IMPORTANT]
> If you're deploying to Android 11 or higher, enable the "Always install with package manager" option in the run configurations. Without this option enabled, you might face issues such as Android Studio running an older version of the extension without the modifications you might have done.
> If you're deploying to Android 11 or higher, enable the `Always install with package manager` option in the run configurations. Without this option enabled, you might face issues such as Android Studio running an older version of the extension without the modifications you might have done.
## Debugging
### Android Debugger
> [!IMPORTANT]
> If you didn't build the main app from source with debug enabled and are using a release/beta APK, you **need** a rooted device.
> If you are using an emulator instead, make sure you choose a profile **without** Google Play.
> If you didn't **build the main app** from source with **debug enabled** and are using a release/beta APK, you **need a rooted device**.
> If you are using an **emulator** instead, make sure you choose a profile **without Google Play**.
You can leverage the Android Debugger to step through your extension while debugging.
Follow the steps above for building and running locally if you haven't already. Debugging will not work if you did not follow the steps above.
You can leverage the Android Debugger to add breakpoints and step through your extension while debugging.
You *cannot* simply use Android Studio's `Debug 'module.name'` -> this will most likely result in an
error while launching.
Instead, once you've built and installed your extension on the target device, use
`Attach Debugger to Android Process` to start debugging Tachiyomi.
Instead, once you've built and installed your extension on the target device, use
`Attach Debugger to Android Process` to start debugging the app.
![](https://i.imgur.com/muhXyfu.png)
Inside the `Attach Debugger to Android Process` window, once the app is running on your device and `Show all processes` is checked, you should be able to select `app.mihon.dev` and press OK.
![](https://i.imgur.com/SUhdB52.png)
### Logs

View File

@ -18,6 +18,7 @@ android {
dependencies {
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
implementation(project(":core"))
}
tasks.register("printDependentExtensions") {

View File

@ -15,12 +15,30 @@ val jsonInstance: Json by injectLazy()
inline fun <reified T> String.parseAs(json: Json = jsonInstance): T =
json.decodeFromString(this)
/**
* Parses JSON string into an object of type [T], applying a [transform] function to the string before parsing.
*
* @param json The [Json] instance to use for deserialization.
* @param transform A function to transform the original JSON string before it is parsed.
*/
inline fun <reified T> String.parseAs(json: Json = jsonInstance, transform: (String) -> String): T =
transform(this).parseAs(json)
/**
* Parses the response body into an object of type [T].
*/
inline fun <reified T> Response.parseAs(json: Json = jsonInstance): T =
use { json.decodeFromStream(body.byteStream()) }
/**
* Parses the response body into an object of type [T], applying a transformation to the raw JSON string before parsing.
*
* @param json The [Json] instance to use for parsing. Defaults to the injected instance.
* @param transform A function to transform the JSON string before it's decoded.
*/
inline fun <reified T> Response.parseAs(json: Json = jsonInstance, transform: (String) -> String): T =
body.string().parseAs(json, transform)
/**
* Serializes the object to a JSON string.
*/

View File

@ -1,9 +0,0 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 9
dependencies {
api(project(":lib:speedbinb"))
}

View File

@ -1,129 +0,0 @@
package eu.kanade.tachiyomi.multisrc.comicgamma
import eu.kanade.tachiyomi.lib.speedbinb.SpeedBinbInterceptor
import eu.kanade.tachiyomi.lib.speedbinb.SpeedBinbReader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import kotlinx.serialization.json.Json
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Evaluator
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
open class ComicGamma(
override val name: String,
override val baseUrl: String,
override val lang: String = "ja",
) : ParsedHttpSource() {
override val supportsLatest = false
private val json = Injekt.get<Json>()
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(SpeedBinbInterceptor(json))
.build()
override fun popularMangaRequest(page: Int) = GET("$baseUrl/manga/", headers)
override fun popularMangaNextPageSelector(): String? = null
override fun popularMangaSelector() = ".tab_panel.active .manga_item"
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
url = element.selectFirst(Evaluator.Tag("a"))!!.attr("href")
title = element.selectFirst(Evaluator.Class("manga_title"))!!.text()
author = element.selectFirst(Evaluator.Class("manga_author"))!!.text()
val genreList = element.select(Evaluator.Tag("li")).map { it.text() }
genre = genreList.joinToString()
status = when {
genreList.contains("完結") && !genreList.contains("リピート配信") -> SManga.COMPLETED
else -> SManga.ONGOING
}
thumbnail_url = element.selectFirst(Evaluator.Tag("img"))!!.absUrl("src")
}
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
fetchPopularManga(page).map { p -> MangasPage(p.mangas.filter { it.title.contains(query) }, false) }
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException()
override fun searchMangaSelector() = throw UnsupportedOperationException()
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
throw UnsupportedOperationException()
private val reader by lazy { SpeedBinbReader(client, headers, json) }
override fun pageListParse(document: Document) = reader.pageListParse(document)
override fun mangaDetailsParse(document: Document): SManga {
val titleElement = document.selectFirst(Evaluator.Class("manga__title"))!!
val titleName = titleElement.child(0).text()
val desc = document.selectFirst(".detail__item > p:not(:empty)")?.run {
select(Evaluator.Tag("br")).prepend("\\n")
this.text().replace("\\n", "\n").replace("\n ", "\n")
}
val listResponse = client.newCall(popularMangaRequest(0)).execute()
val manga = popularMangaParse(listResponse).mangas.find { it.title == titleName }
return manga?.apply { description = desc } ?: SManga.create().apply {
author = titleElement.child(1).text()
description = desc
status = SManga.UNKNOWN
val slug = document.location().removeSuffix("/").substringAfterLast("/")
thumbnail_url = "$baseUrl/img/manga_thumb/${slug}_list.jpg"
}
}
override fun chapterListSelector() = ".read__area .read__outer > a:not([href=#comics])"
override fun chapterFromElement(element: Element) = SChapter.create().apply {
url = element.attr("href").toOldChapterUrl()
val number = url.removeSuffix("/").substringAfterLast('/').replace('_', '.')
val list = element.selectFirst(Evaluator.Class("read__contents"))!!.children()
name = "[$number] ${list[0].text()}"
if (list.size >= 3) {
date_upload = dateFormat.parseJST(list[2].text())?.time ?: 0L
}
}
override fun pageListRequest(chapter: SChapter) =
GET(baseUrl + chapter.url.toNewChapterUrl(), headers)
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
companion object {
internal fun SimpleDateFormat.parseJST(date: String) = parse(date)?.apply {
time += 12 * 3600 * 1000 // updates at 12 noon
}
private fun getJSTFormat(datePattern: String) =
SimpleDateFormat(datePattern, Locale.JAPANESE).apply {
timeZone = TimeZone.getTimeZone("GMT+09:00")
}
private val dateFormat by lazy { getJSTFormat("yyyy年M月dd日") }
private fun String.toOldChapterUrl(): String {
// ../../../_files/madeinabyss/063_2/
val segments = split('/')
val size = segments.size
val slug = segments[size - 3]
val number = segments[size - 2]
return "/manga/$slug/_files/$number/"
}
private fun String.toNewChapterUrl(): String {
val segments = split('/')
return "/_files/${segments[2]}/${segments[4]}/"
}
}
}

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 4
baseVersionCode = 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,244 @@
package eu.kanade.tachiyomi.multisrc.comiciviewer
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstance
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
abstract class ComiciViewer(
override val name: String,
override val baseUrl: String,
override val lang: String,
) : ConfigurableSource, HttpSource() {
private val preferences: SharedPreferences by getPreferencesLazy()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.JAPAN)
override val supportsLatest = true
override val client = super.client.newBuilder()
.addInterceptor(ImageInterceptor())
.build()
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/ranking/manga", headers)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select("div.ranking-box-vertical, div.ranking-box-vertical-top3").map { element ->
SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
title = element.selectFirst(".title-text")!!.text()
thumbnail_url = element.selectFirst("source")?.attr("data-srcset")?.substringBefore(" ")?.let { "https:$it" }
}
}
return MangasPage(mangas, false)
}
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/category/manga", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select("div.category-box-vertical").map { element ->
SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
title = element.selectFirst(".title-text")!!.text()
thumbnail_url = element.selectFirst("source")?.attr("data-srcset")?.substringBefore(" ")?.let { "https:$it" }
}
}
return MangasPage(mangas, false)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotBlank()) {
val url = "$baseUrl/search".toHttpUrl().newBuilder()
.addQueryParameter("keyword", query)
.addQueryParameter("page", (page - 1).toString())
.addQueryParameter("filter", "series")
.build()
return GET(url, headers)
}
val filterList = if (filters.isEmpty()) getFilterList() else filters
val browseFilter = filterList.firstInstance<BrowseFilter>()
val pathAndQuery = getFilterOptions()[browseFilter.state].second
val url = (baseUrl + pathAndQuery).toHttpUrl().newBuilder().build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val url = response.request.url.toString()
return when {
url.contains("/ranking/") -> popularMangaParse(response)
url.contains("/category/") -> latestUpdatesParse(response)
else -> {
val document = response.asJsoup()
val mangas = document.select("div.manga-store-item").map { element ->
SManga.create().apply {
setUrlWithoutDomain(
element.selectFirst("a.c-ms-clk-article")!!.attr("href"),
)
title = element.selectFirst("h2.manga-title")!!.text()
thumbnail_url =
element.selectFirst("source")?.attr("data-srcset")?.substringBefore(" ")
?.let { "https:$it" }
}
}
val hasNextPage = document.selectFirst("li.mode-paging-active + li > a") != null
return MangasPage(mangas, hasNextPage)
}
}
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
return SManga.create().apply {
title = document.select("h1.series-h-title span").last()!!.text()
author = document.select("div.series-h-credit-user").text()
artist = author
description = document.selectFirst("div.series-h-credit-info-text-text")?.text()
genre = document.select("a.series-h-tag-link").joinToString { it.text().removePrefix("#") }
thumbnail_url = document.selectFirst("div.series-h-img source")?.attr("data-srcset")?.substringBefore(" ")?.let { "https:$it" }
}
}
override fun chapterListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url + "/list?s=1", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val showLocked = preferences.getBoolean(SHOW_LOCKED_PREF_KEY, true)
val document = response.asJsoup()
return document.select("div.series-ep-list-item").mapNotNull { element ->
val link = element.selectFirst("a.g-episode-link-wrapper")!!
val isFree = element.selectFirst("span.free-icon-new") != null
val isTicketLocked = element.selectFirst("img[data-src*='free_charge_ja.svg']") != null
val isCoinLocked = element.selectFirst("img[data-src*='coin.svg']") != null
val isLocked = !isFree
if (!showLocked && isLocked) {
return@mapNotNull null
}
SChapter.create().apply {
val chapterUrl = link.attr("data-href")
if (chapterUrl.isNotEmpty()) {
setUrlWithoutDomain(chapterUrl)
} else {
url = response.request.url.toString() + "#" + link.attr("data-article") + DUMMY_URL_SUFFIX
}
name = link.selectFirst("span.series-ep-list-item-h-text")!!.text()
when {
isTicketLocked -> name = "🔒 $name"
isCoinLocked -> name = "\uD83E\uDE99 $name"
}
date_upload = dateFormat.tryParse(element.selectFirst("time")?.attr("datetime"))
}
}
}
override fun pageListRequest(chapter: SChapter): Request {
if (chapter.url.endsWith(DUMMY_URL_SUFFIX)) {
throw Exception("Log in via WebView to read purchased chapters and refresh the entry")
}
return super.pageListRequest(chapter)
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val viewer = document.selectFirst("#comici-viewer") ?: throw Exception("You need to log in via WebView to read this chapter or purchase this chapter")
val comiciViewerId = viewer.attr("comici-viewer-id")
val memberJwt = viewer.attr("data-member-jwt")
val requestUrl = "$baseUrl/book/contentsInfo".toHttpUrl().newBuilder()
.addQueryParameter("comici-viewer-id", comiciViewerId)
.addQueryParameter("user-id", memberJwt)
.addQueryParameter("page-from", "0")
val pageTo = client.newCall(GET(requestUrl.addQueryParameter("page-to", "1").build(), headers))
.execute().use { initialResponse ->
if (!initialResponse.isSuccessful) {
throw Exception("Failed to get page list")
}
initialResponse.parseAs<ViewerResponse>().totalPages.toString()
}
val getAllPagesUrl = requestUrl.setQueryParameter("page-to", pageTo).build()
return client.newCall(GET(getAllPagesUrl, headers)).execute().use { allPagesResponse ->
if (allPagesResponse.isSuccessful) {
allPagesResponse.parseAs<ViewerResponse>().result.map { resultItem ->
val urlBuilder = resultItem.imageUrl.toHttpUrl().newBuilder()
if (resultItem.scramble.isNotEmpty()) {
urlBuilder.addQueryParameter("scramble", resultItem.scramble)
}
Page(
index = resultItem.sort,
imageUrl = urlBuilder.build().toString(),
)
}.sortedBy { it.index }
} else {
throw Exception("Failed to get full page list")
}
}
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_LOCKED_PREF_KEY
title = "Show locked chapters"
setDefaultValue(true)
}.also(screen::addPreference)
}
protected open class BrowseFilter(vals: Array<String>) : Filter.Select<String>("Filter by", vals)
protected open fun getFilterOptions(): List<Pair<String, String>> = listOf(
Pair("ランキング", "/ranking/manga"),
Pair("読み切り", "/category/manga?type=読み切り"),
Pair("完結", "/category/manga?type=完結"),
Pair("月曜日", "/category/manga?type=連載中&day=月"),
Pair("火曜日", "/category/manga?type=連載中&day=火"),
Pair("水曜日", "/category/manga?type=連載中&day=水"),
Pair("木曜日", "/category/manga?type=連載中&day=木"),
Pair("金曜日", "/category/manga?type=連載中&day=金"),
Pair("土曜日", "/category/manga?type=連載中&day=土"),
Pair("日曜日", "/category/manga?type=連載中&day=日"),
Pair("その他", "/category/manga?type=連載中&day=その他"),
)
override fun getFilterList() = FilterList(
BrowseFilter(getFilterOptions().map { it.first }.toTypedArray()),
)
// Unsupported
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
companion object {
private const val SHOW_LOCKED_PREF_KEY = "pref_show_locked_chapters"
private const val DUMMY_URL_SUFFIX = "NeedLogin"
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.multisrc.comiciviewer
import kotlinx.serialization.Serializable
@Serializable
class ViewerResponse(
val result: List<PageDto>,
val totalPages: Int,
)
@Serializable
class PageDto(
val imageUrl: String,
val scramble: String,
val sort: Int,
)
@Serializable
class TilePos(
val x: Int,
val y: Int,
)

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.extension.all.comicgrowl
package eu.kanade.tachiyomi.multisrc.comiciviewer
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@ -10,28 +10,37 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayOutputStream
object ImageDescrambler {
class ImageInterceptor : Interceptor {
// Left-top corner position
private class TilePos(val x: Int, val y: Int)
/**
* Interceptor to descramble the image.
*/
fun interceptor(chain: Interceptor.Chain): Response {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val scrambleData = request.url.queryParameter("scramble")
if (scrambleData.isNullOrEmpty()) {
return chain.proceed(request)
}
val newUrl = request.url.newBuilder()
.removeAllQueryParameters("scramble")
.build()
val newRequest = request.newBuilder().url(newUrl).build()
val response = chain.proceed(newRequest)
if (!response.isSuccessful) {
return response
}
val scramble = request.url.fragment ?: return response // return if no scramble fragment
val tiles = buildList {
scramble.split("-").forEachIndexed { index, s ->
val scrambleInt = s.toInt()
add(index, TilePos(scrambleInt / 4, scrambleInt % 4))
scrambleData.drop(1).dropLast(1).replace(" ", "").split(",").forEach {
val scrambleInt = it.toInt()
add(TilePos(scrambleInt / 4, scrambleInt % 4))
}
}
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
val descrambledImg = drawDescrambledImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
val descrambledImg =
unscrambleImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
val output = ByteArrayOutputStream()
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
@ -41,20 +50,27 @@ object ImageDescrambler {
return response.newBuilder().body(body).build()
}
private fun drawDescrambledImage(rawImage: Bitmap, width: Int, height: Int, tiles: List<TilePos>): Bitmap {
// Prepare canvas
private fun unscrambleImage(
rawImage: Bitmap,
width: Int,
height: Int,
tiles: List<TilePos>,
): Bitmap {
val descrambledImg = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(descrambledImg)
// Tile width and height(4x4)
val tileWidth = width / 4
val tileHeight = height / 4
// Draw rect
var count = 0
for (x in 0..3) {
for (y in 0..3) {
val desRect = Rect(x * tileWidth, y * tileHeight, (x + 1) * tileWidth, (y + 1) * tileHeight)
val desRect = Rect(
x * tileWidth,
y * tileHeight,
(x + 1) * tileWidth,
(y + 1) * tileHeight,
)
val srcRect = Rect(
tiles[count].x * tileWidth,
tiles[count].y * tileHeight,

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 5
baseVersionCode = 6

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.multisrc.greenshit
import android.content.SharedPreferences
import android.util.Base64
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
@ -31,8 +30,6 @@ import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.io.IOException
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
abstract class GreenShit(
override val name: String,
@ -196,31 +193,11 @@ abstract class GreenShit(
private fun pageListRequestMobile(chapter: SChapter): Request {
val pathSegment = chapter.url.replace("capitulo", "capitulo-app-token")
val newHeaders = headers.newBuilder()
.set("x-client-hash", generateToken(scanId, SECRET_KEY))
.set("authorization", "Bearer $token")
.build()
return GET("$apiUrl$pathSegment", newHeaders)
}
private fun generateToken(scanId: Long, secretKey: String): String {
val timestamp = System.currentTimeMillis() / 1000
val expiration = timestamp + 3600
val payload = buildJsonObject {
put("scan_id", scanId)
put("timestamp", timestamp)
put("exp", expiration)
}.toJsonString()
val hmac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
hmac.init(secretKeySpec)
val signatureBytes = hmac.doFinal(payload.toByteArray())
val signature = signatureBytes.joinToString("") { "%02x".format(it) }
return Base64.encodeToString("$payload.$signature".toByteArray(), Base64.NO_WRAP)
}
override fun pageListParse(response: Response): List<Page> =
when (contentOrigin) {
ContentOrigin.Mobile -> pageListParseMobile(response)
@ -444,7 +421,5 @@ abstract class GreenShit(
private const val TOKEN_PREF = "greenShitToken"
private const val USERNAME_PREF = "usernamePref"
private const val PASSWORD_PREF = "passwordPref"
private const val SECRET_KEY = "sua_chave_secreta_aqui_32_caracteres"
}
}

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 3
baseVersionCode = 4

View File

@ -0,0 +1,135 @@
@file:Suppress("PrivatePropertyName", "PropertyName")
package eu.kanade.tachiyomi.multisrc.hentaihand
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/**
* Created by ipcjs on 2025/9/23.
*/
@Serializable
class ResponseDto<T>(
val data: T,
val next_page_url: String?,
)
@Serializable
class LoginResponseDto(val auth: AuthDto) {
@Serializable
class AuthDto(val access_token: String)
}
@Serializable
class PageListResponseDto(val images: List<PageDto>) {
fun toPageList() = images.map { Page(it.page, "", it.source_url) }
@Serializable
class PageDto(
val page: Int,
val source_url: String,
)
}
typealias ChapterListResponseDto = List<ChapterDto>
typealias ChapterResponseDto = ChapterDto
@Serializable
class ChapterDto(
private val slug: String,
private val name: String?,
private val added_at: String?,
private val updated_at: String?,
) {
companion object {
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
}
private fun parseDate(date: String?): Long =
if (date == null) {
0
} else if (date.contains("day")) {
Calendar.getInstance().apply {
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
}.timeInMillis
} else {
DATE_FORMAT.parse(date)?.time ?: 0
}
fun toSChapter(slug: String) = SChapter.create().also { chapter ->
chapter.url = "$slug/${this.slug}"
chapter.name = name ?: "Chapter"
chapter.date_upload = parseDate(added_at)
}
fun toSChapter() = SChapter.create().also { chapter ->
chapter.url = slug
chapter.name = "Chapter"
chapter.date_upload = parseDate(updated_at)
chapter.chapter_number = 1f
}
}
typealias MangaDetailsResponseDto = MangaDto
@Serializable
class MangaDto(
private val slug: String,
private val title: String,
private val image_url: String?,
private val artists: List<NameDto>?,
private val authors: List<NameDto>?,
private val tags: List<NameDto>?,
private val relationships: List<NameDto>?,
private val status: String?,
private val alternative_title: String?,
private val groups: List<NameDto>?,
private val description: String?,
private val pages: Int?,
private val category: NameDto?,
private val language: NameDto?,
private val parodies: List<NameDto>?,
private val characters: List<NameDto>?,
) {
fun toSManga() = SManga.create().also { manga ->
manga.url = slug.prependIndent("/en/comic/")
manga.title = title
manga.thumbnail_url = image_url
}
fun toSMangaDetails() = toSManga().also { manga ->
manga.artist = artists?.toNames()
manga.author = authors?.toNames() ?: manga.artist
manga.genre = listOfNotNull(tags, relationships).flatten().toNames()
manga.status = when (status) {
"complete" -> SManga.COMPLETED
"ongoing" -> SManga.ONGOING
"onhold" -> SManga.ONGOING
"canceled" -> SManga.COMPLETED
else -> SManga.COMPLETED
}
manga.description = listOf(
Pair("Alternative Title", alternative_title),
Pair("Groups", groups?.toNames()),
Pair("Description", description),
Pair("Pages", pages?.toString()),
Pair("Category", category?.name),
Pair("Language", language?.name),
Pair("Parodies", parodies?.toNames()),
Pair("Characters", characters?.toNames()),
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
}
}
@Serializable
class NameDto(val name: String)
fun List<NameDto>.toNames() = if (this.isEmpty()) null else this.joinToString { it.name }
@Serializable
class IdDto(val id: String)

View File

@ -16,13 +16,8 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import keiyoushi.utils.parseAs
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
@ -32,10 +27,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
abstract class HentaiHand(
@ -48,32 +41,15 @@ abstract class HentaiHand(
override val supportsLatest = true
private val json: Json by injectLazy()
private fun slugToUrl(json: JsonObject) = json["slug"]!!.jsonPrimitive.content.prependIndent("/en/comic/")
private fun jsonArrayToString(arrayKey: String, obj: JsonObject): String? {
val array = obj[arrayKey]!!.jsonArray
if (array.isEmpty()) return null
return array.joinToString(", ") {
it.jsonObject["name"]!!.jsonPrimitive.content
}
}
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
// Popular
override fun popularMangaParse(response: Response): MangasPage {
val jsonResponse = json.parseToJsonElement(response.body.string())
val mangaList = jsonResponse.jsonObject["data"]!!.jsonArray.map {
val obj = it.jsonObject
SManga.create().apply {
url = slugToUrl(obj)
title = obj["title"]!!.jsonPrimitive.content
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
}
}
val hasNextPage = jsonResponse.jsonObject["next_page_url"]!!.jsonPrimitive.content.isNotEmpty()
return MangasPage(mangaList, hasNextPage)
val resp = response.parseAs<ResponseDto<List<MangaDto>>>()
val hasNextPage = !resp.next_page_url.isNullOrEmpty()
return MangasPage(resp.data.map { it.toSManga() }, hasNextPage)
}
override fun popularMangaRequest(page: Int): Request {
@ -116,9 +92,7 @@ abstract class HentaiHand(
.subscribeOn(Schedulers.io())
.map { response ->
// Returns the first matched id, or null if there are no results
val idList = json.parseToJsonElement(response.body.string()).jsonObject["data"]!!.jsonArray.map {
it.jsonObject["id"]!!.jsonPrimitive.content
}
val idList = response.parseAs<ResponseDto<List<IdDto>>>().data.map { it.id }
if (idList.isEmpty()) {
return@map null
} else {
@ -177,33 +151,7 @@ abstract class HentaiHand(
}
override fun mangaDetailsParse(response: Response): SManga {
val obj = json.parseToJsonElement(response.body.string()).jsonObject
return SManga.create().apply {
url = slugToUrl(obj)
title = obj["title"]!!.jsonPrimitive.content
thumbnail_url = obj["image_url"]!!.jsonPrimitive.content
artist = jsonArrayToString("artists", obj)
author = jsonArrayToString("authors", obj) ?: artist
genre = listOfNotNull(jsonArrayToString("tags", obj), jsonArrayToString("relationships", obj)).joinToString(", ")
status = when (obj["status"]!!.jsonPrimitive.content) {
"complete" -> SManga.COMPLETED
"ongoing" -> SManga.ONGOING
"onhold" -> SManga.ONGOING
"canceled" -> SManga.COMPLETED
else -> SManga.COMPLETED
}
description = listOf(
Pair("Alternative Title", obj["alternative_title"]!!.jsonPrimitive.content),
Pair("Groups", jsonArrayToString("groups", obj)),
Pair("Description", obj["description"]!!.jsonPrimitive.content),
Pair("Pages", obj["pages"]!!.jsonPrimitive.content),
Pair("Category", try { obj["category"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
Pair("Language", try { obj["language"]!!.jsonObject["name"]!!.jsonPrimitive.content } catch (_: Exception) { null }),
Pair("Parodies", jsonArrayToString("parodies", obj)),
Pair("Characters", jsonArrayToString("characters", obj)),
).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}: ${it.second}" }
}
return response.parseAs<MangaDetailsResponseDto>().toSMangaDetails()
}
// Chapters
@ -220,40 +168,13 @@ abstract class HentaiHand(
override fun chapterListRequest(manga: SManga): Request = chapterListApiRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
val slug = response.request.url.toString().substringAfter("/api/comics/").removeSuffix("/chapters")
return if (chapters) {
val array = json.parseToJsonElement(response.body.string()).jsonArray
array.map {
SChapter.create().apply {
url = "$slug/${it.jsonObject["slug"]!!.jsonPrimitive.content}"
name = it.jsonObject["name"]!!.jsonPrimitive.content
val date = it.jsonObject["added_at"]!!.jsonPrimitive.content
date_upload = if (date.contains("day")) {
Calendar.getInstance().apply {
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
}.timeInMillis
} else {
DATE_FORMAT.parse(it.jsonObject["added_at"]!!.jsonPrimitive.content)?.time ?: 0
}
}
}
return if (this.chapters) {
val slug = response.request.url.toString()
.substringAfter("/api/comics/")
.removeSuffix("/chapters")
response.parseAs<ChapterListResponseDto>().map { it.toSChapter(slug) }
} else {
val obj = json.parseToJsonElement(response.body.string()).jsonObject
listOf(
SChapter.create().apply {
url = obj["slug"]!!.jsonPrimitive.content
name = "Chapter"
val date = obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content
date_upload = if (date.contains("day")) {
Calendar.getInstance().apply {
add(Calendar.DATE, -date.filter { it.isDigit() }.toInt())
}.timeInMillis
} else {
DATE_FORMAT.parse(obj.jsonObject["uploaded_at"]!!.jsonPrimitive.content)?.time ?: 0
}
chapter_number = 1f
},
)
listOf(response.parseAs<ChapterResponseDto>().toSChapter())
}
}
@ -264,13 +185,7 @@ abstract class HentaiHand(
return GET("$baseUrl/api/comics/$slug/images")
}
override fun pageListParse(response: Response): List<Page> =
json.parseToJsonElement(response.body.string()).jsonObject["images"]!!.jsonArray.map {
val imgObj = it.jsonObject
val index = imgObj["page"]!!.jsonPrimitive.int
val imgUrl = imgObj["source_url"]!!.jsonPrimitive.content
Page(index, "", imgUrl)
}
override fun pageListParse(response: Response): List<Page> = response.parseAs<PageListResponseDto>().toPageList()
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
@ -278,7 +193,10 @@ abstract class HentaiHand(
protected fun authIntercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (username.isEmpty() or password.isEmpty()) {
if (username.isEmpty() or password.isEmpty()
// image request doesn't need token
or !request.url.toString().startsWith(baseUrl)
) {
return chain.proceed(request)
}
@ -304,7 +222,7 @@ abstract class HentaiHand(
}
try {
// Returns access token as a string, unless unparseable
return json.parseToJsonElement(response.body.string()).jsonObject["auth"]!!.jsonObject["access-token"]!!.jsonPrimitive.content
return response.parseAs<LoginResponseDto>().auth.access_token
} catch (e: IllegalArgumentException) {
throw IOException("Cannot parse login response body")
}

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 10
baseVersionCode = 11

View File

@ -107,7 +107,7 @@ class Chapter(
fun isLocked() = (isLocked == true) || (isTimeLocked == true)
fun toSChapter(mangaSlug: String?) = SChapter.create().apply {
val prefix = if (isLocked()) "🔒 " else ""
val prefix = if (!isAccessible()) "🔒 " else ""
val seriesSlug = mangaSlug ?: mangaPost.slug
url = "/series/$seriesSlug/$slug#$id"
name = "${prefix}Chapter $number"

View File

@ -57,10 +57,12 @@ abstract class Iken(
override fun popularMangaRequest(page: Int) = GET("$baseUrl/home", headers)
protected open val popularMangaSelector = "aside a:has(img), .splide:has(.card) li a:has(img)"
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val entries = document.select("aside a:has(img)").mapNotNull {
val entries = document.select(popularMangaSelector).mapNotNull {
titleCache[it.absUrl("href").substringAfter("series/")]?.toSManga()
}
@ -171,7 +173,7 @@ abstract class Iken(
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = showLockedChapterPrefKey
title = "Show locked chapters"
title = "Show inaccessible chapters"
setDefaultValue(false)
}.also(screen::addPreference)
}

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 17
baseVersionCode = 18
dependencies {
api(project(":lib:i18n"))

View File

@ -215,7 +215,9 @@ abstract class Keyoapp(
protected open val statusSelector: String = "div:has(span:containsOwn(Status)) ~ div"
protected open val authorSelector: String = "div:has(span:containsOwn(Author)) ~ div"
protected open val artistSelector: String = "div:has(span:containsOwn(Artist)) ~ div"
protected open val genreSelector: String = "div:has(span:containsOwn(Type)) ~ div"
protected open val genreSelector: String = "div.grid:has(>h1) > div > a:not([title='Status'])"
protected open val typeSelector: String = "div:has(span:containsOwn(Type)) ~ div"
protected open val dateSelector: String = ".text-xs"
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
@ -226,16 +228,16 @@ abstract class Keyoapp(
author = document.selectFirst(authorSelector)?.text()
artist = document.selectFirst(artistSelector)?.text()
genre = buildList {
document.selectFirst(genreSelector)?.text()?.replaceFirstChar {
document.selectFirst(typeSelector)?.text()?.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(
Locale.getDefault(),
Locale.ENGLISH,
)
} else {
it.toString()
}
}.let(::add)
document.select("div.grid:has(>h1) > div > a").forEach { add(it.text()) }
document.select(genreSelector).forEach { add(it.text()) }
}.joinToString()
}

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 44
baseVersionCode = 45
dependencies {
api(project(":lib:cryptoaes"))

View File

@ -919,7 +919,7 @@ abstract class Madara(
WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> {
parseRelativeDate(date)
}
WordSet("hace").startsWith(date) -> {
WordSet("hace", "giờ", "phút", "giây").startsWith(date) -> {
parseRelativeDate(date)
}
// Handle "jour" with a number before it

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 19
baseVersionCode = 20

View File

@ -141,7 +141,7 @@ abstract class MadTheme(
title = element.selectFirst("a")!!.attr("title")
element.selectFirst(".summary")?.text()?.let { description = it }
element.select(".genres > *").joinToString { it.text() }.takeIf { it.isNotEmpty() }?.let { genre = it }
thumbnail_url = element.selectFirst("img")!!.attr("abs:data-src")
thumbnail_url = element.selectFirst("img")!!.attr("abs:data-src") + "#image-request"
}
/*
@ -155,7 +155,7 @@ abstract class MadTheme(
title = document.selectFirst(".detail h1")!!.text()
author = document.select(".detail .meta > p > strong:contains(Authors) ~ a").joinToString { it.text().trim(',', ' ') }
genre = document.select(".detail .meta > p > strong:contains(Genres) ~ a").joinToString { it.text().trim(',', ' ') }
thumbnail_url = document.selectFirst("#cover img")!!.attr("abs:data-src")
thumbnail_url = document.selectFirst("#cover img")!!.attr("abs:data-src") + "#image-request"
val altNames = document.selectFirst(".detail h2")?.text()
?.split(',', ';')

View File

@ -2,4 +2,4 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 8
baseVersionCode = 9

View File

@ -211,7 +211,7 @@ abstract class MangaBox(
}
}
override fun searchMangaSelector() = ".panel_story_list .story_item, div.list-truyen-item-wrap, .list-comic-item-wrap .list-story-item"
override fun searchMangaSelector() = ".panel_story_list .story_item, div.list-truyen-item-wrap, div.list-comic-item-wrap"
override fun searchMangaFromElement(element: Element) = mangaFromElement(element)

View File

@ -0,0 +1,9 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

View File

@ -0,0 +1,82 @@
package eu.kanade.tachiyomi.multisrc.natsuid
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.toJsonString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
@Serializable
class Term(
val name: String,
val slug: String,
val taxonomy: String,
)
@Serializable
class Manga(
val id: Int,
val slug: String,
val title: Rendered,
val content: Rendered,
@SerialName("_embedded")
val embedded: Embedded,
) {
fun toSManga(appendId: Boolean = false) = SManga.create().apply {
url = MangaUrl(id, slug).toJsonString()
title = Parser.unescapeEntities(this@Manga.title.rendered, false)
description = buildString {
append(Jsoup.parseBodyFragment(content.rendered).wholeText())
if (appendId) {
append("\n\nID: $id")
}
}
thumbnail_url = embedded.featuredMedia.firstOrNull()?.sourceUrl
author = embedded.getTerms("series-author").joinToString()
artist = embedded.getTerms("artist").joinToString()
genre = buildSet {
addAll(embedded.getTerms("genre"))
addAll(embedded.getTerms("type"))
}.joinToString()
status = with(embedded.getTerms("status")) {
when {
contains("Ongoing") -> SManga.ONGOING
contains("Completed") -> SManga.COMPLETED
contains("Cancelled") -> SManga.CANCELLED
contains("On Hiatus") -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
}
initialized = true
}
}
@Serializable
class Embedded(
@SerialName("wp:featuredmedia")
val featuredMedia: List<FeaturedMedia>,
@SerialName("wp:term")
private val terms: List<List<Term>>,
) {
fun getTerms(type: String): List<String> {
return terms.find { it.getOrNull(0)?.taxonomy == type }?.map { it.name } ?: emptyList()
}
}
@Serializable
class FeaturedMedia(
@SerialName("source_url")
val sourceUrl: String,
)
@Serializable
class Rendered(
val rendered: String,
)
@Serializable
class MangaUrl(
val id: Int,
val slug: String,
)

View File

@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.multisrc.natsuid
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
abstract class SelectFilter<T>(
name: String,
private val options: List<Pair<String, T>>,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second
}
class CheckBoxFilter<T>(name: String, val value: T) : Filter.CheckBox(name)
abstract class CheckBoxGroup<T>(
name: String,
options: List<Pair<String, T>>,
) : Filter.Group<CheckBoxFilter<T>>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }
}
class TriStateFilter<T>(name: String, val value: T) : Filter.TriState(name)
abstract class TriStateGroupFilter<T>(
name: String,
options: List<Pair<String, T>>,
) : Filter.Group<TriStateFilter<T>>(
name,
options.map { TriStateFilter(it.first, it.second) },
) {
val included get() = state.filter { it.isIncluded() }.map { it.value }
val excluded get() = state.filter { it.isExcluded() }.map { it.value }
}
class SortFilter(
selection: Int = 0,
) : Filter.Sort(
name = "Sort",
values = sortBy.map { it.first }.toTypedArray(),
state = Selection(selection, false),
) {
val sort get() = sortBy[state?.index ?: 0].second
val isAscending get() = state?.ascending ?: false
companion object {
private val sortBy = listOf(
"Popular" to "popular",
"Rating" to "rating",
"Updated" to "updated",
"Bookmarked" to "bookmarked",
"Title" to "title",
)
val popular = FilterList(SortFilter(0))
val latest = FilterList(SortFilter(2))
}
}
class GenreFilter(
genres: List<Pair<String, String>>,
) : TriStateGroupFilter<String>("Genre", genres)
class GenreInclusion : SelectFilter<String>(
name = "Genre Inclusion Mode",
options = listOf(
"OR" to "OR",
"AND" to "AND",
),
)
class GenreExclusion : SelectFilter<String>(
name = "Genre Exclusion Mode",
options = listOf(
"OR" to "OR",
"AND" to "AND",
),
)
class TypeFilter : CheckBoxGroup<String>(
name = "Type",
options = listOf(
"Manga" to "manga",
"Manhwa" to "manhwa",
"Manhua" to "manhua",
),
)
class StatusFilter : CheckBoxGroup<String>(
name = "Status",
options = listOf(
"Ongoing" to "ongoing",
"Completed" to "completed",
"Cancelled" to "cancelled",
"On Hiatus" to "on-hiatus",
"Unknown" to "unknown",
),
)

View File

@ -0,0 +1,360 @@
package eu.kanade.tachiyomi.multisrc.natsuid
import android.util.Log
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstance
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString
import keiyoushi.utils.tryParse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import okhttp3.CacheControl
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.closeQuietly
import okio.IOException
import org.jsoup.Jsoup
import rx.Observable
import java.lang.UnsupportedOperationException
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.random.Random
// https://themesinfo.com/natsu_id-theme-wordpress-c8x1c Wordpress Theme Author "Dzul Qurnain"
abstract class NatsuId(
override val name: String,
override val lang: String,
override val baseUrl: String,
val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US),
) : HttpSource() {
override val supportsLatest: Boolean = true
protected open fun OkHttpClient.Builder.customizeClient(): OkHttpClient.Builder = this
final override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.customizeClient()
// fix disk cache
.apply {
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
}
.build()
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int) =
searchMangaRequest(page, "", SortFilter.popular)
override fun popularMangaParse(response: Response) =
searchMangaParse(response)
override fun latestUpdatesRequest(page: Int) =
searchMangaRequest(page, "", SortFilter.latest)
override fun latestUpdatesParse(response: Response) =
searchMangaParse(response)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith("https://")) {
deepLink(query)
} else {
super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/wp-admin/admin-ajax.php?action=advanced_search"
val body = MultipartBody.Builder().apply {
setType(MultipartBody.FORM)
addFormDataPart("nonce", getNonce())
filters.firstInstanceOrNull<GenreInclusion>()?.selected.also {
addFormDataPart("inclusion", it ?: "OR")
}
filters.firstInstanceOrNull<GenreExclusion>()?.selected.also {
addFormDataPart("exclusion", it ?: "OR")
}
addFormDataPart("page", page.toString())
val genres = filters.firstInstanceOrNull<GenreFilter>()
genres?.included.orEmpty().also {
addFormDataPart("genre", it.toJsonString())
}
genres?.excluded.orEmpty().also {
addFormDataPart("genre_exclude", it.toJsonString())
}
addFormDataPart("author", "[]")
addFormDataPart("artist", "[]")
addFormDataPart("project", "0")
filters.firstInstanceOrNull<TypeFilter>()?.checked.orEmpty().also {
addFormDataPart("type", it.toJsonString())
}
val sort = filters.firstInstance<SortFilter>()
addFormDataPart("order", if (sort.isAscending) "asc" else "desc")
addFormDataPart("orderby", sort.sort)
addFormDataPart("query", query.trim())
}.build()
return POST(url, headers, body)
}
private var nonce: String? = null
@Synchronized
private fun getNonce(): String {
if (nonce == null) {
val url = "$baseUrl/wp-admin/admin-ajax.php?type=search_form&action=get_nonce"
val response = client.newCall(GET(url, headers)).execute()
Jsoup.parseBodyFragment(response.body.string())
.selectFirst("input[name=search_nonce]")
?.attr("value")
?.takeIf { it.isNotBlank() }
?.also {
nonce = it
}
}
return nonce ?: throw Exception("Unable to get nonce")
}
private val metadataClient = client.newBuilder()
.addNetworkInterceptor { chain ->
chain.proceed(chain.request()).newBuilder()
.header("Cache-Control", "max-age=${24 * 60 * 60}")
.removeHeader("Pragma")
.removeHeader("Expires")
.build()
}.build()
override fun getFilterList() = runBlocking(Dispatchers.IO) {
val filters: MutableList<Filter<*>> = mutableListOf(
SortFilter(),
TypeFilter(),
StatusFilter(),
)
val url = "$baseUrl/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc"
val response = metadataClient.newCall(
GET(url, headers, CacheControl.FORCE_CACHE),
).await()
if (!response.isSuccessful) {
metadataClient.newCall(
GET(url, headers, CacheControl.FORCE_NETWORK),
).enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
response.closeQuietly()
}
override fun onFailure(call: Call, e: IOException) {
Log.e(name, "Failed to fetch genre filter", e)
}
},
)
filters.addAll(
listOf(
Filter.Separator(),
Filter.Header("Press 'reset' to load genre filter"),
),
)
return@runBlocking FilterList(filters)
}
val data = try {
response.parseAs<List<Term>>(transform = ::transformJsonResponse)
} catch (e: Throwable) {
Log.e(name, "Failed to parse genre filters", e)
filters.addAll(
listOf(
Filter.Separator(),
Filter.Header("Failed to parse genre filter"),
),
)
return@runBlocking FilterList(filters)
}
filters.addAll(
listOf(
GenreFilter(
data.map { it.name to it.slug },
),
GenreInclusion(),
GenreInclusion(),
),
)
FilterList(filters)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
val slugs = document.select("div > a[href*=/manga/]:has(> img)").map {
it.absUrl("href").toHttpUrl().pathSegments[1]
}.ifEmpty {
return MangasPage(emptyList(), false)
}
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder().apply {
slugs.forEach { slug ->
addQueryParameter("slug[]", slug)
}
addQueryParameter("per_page", "${slugs.size + 1}")
addQueryParameter("_embed", null)
}.build()
val details = client.newCall(GET(url, headers)).execute()
.parseAs<List<Manga>>(transform = ::transformJsonResponse)
.filterNot { manga ->
manga.embedded.getTerms("type").contains("Novel")
}
.associateBy { it.slug }
val mangas = slugs.mapNotNull { slug ->
details[slug]?.toSManga()
}
val hasNextPage = document.selectFirst("button:has(svg)") != null
return MangasPage(mangas, hasNextPage)
}
private fun deepLink(url: String): Observable<MangasPage> {
val httpUrl = url.toHttpUrl()
if (
httpUrl.host == baseUrl.toHttpUrl().host &&
httpUrl.pathSegments.size >= 2 &&
httpUrl.pathSegments[0] == "manga"
) {
val slug = httpUrl.pathSegments[1]
val url = "$baseUrl/wp-json/wp/v2/manga".toHttpUrl().newBuilder()
.addQueryParameter("slug[]", slug)
.addQueryParameter("_embed", null)
.build()
return client.newCall(GET(url, headers))
.asObservableSuccess()
.map { response ->
val manga = response.parseAs<List<Manga>>(transform = ::transformJsonResponse)[0]
if (manga.embedded.getTerms("type").contains("Novel")) {
throw Exception("Novels are not supported")
}
MangasPage(listOf(manga.toSManga()), false)
}
}
return Observable.error(Exception("Unsupported url"))
}
private val descriptionIdRegex = Regex("""ID: (\d+)""")
private fun getMangaId(manga: SManga): String {
return if (manga.url.startsWith("{")) {
manga.url.parseAs<MangaUrl>().id.toString()
} else if (descriptionIdRegex.containsMatchIn(manga.description?.trim().orEmpty())) {
descriptionIdRegex.find(manga.description!!.trim())!!.groupValues[1]
} else {
val document = client.newCall(
GET(getMangaUrl(manga), headers),
).execute().asJsoup()
document.selectFirst("#gallery-list")!!.attr("hx-get")
.substringAfter("manga_id=").substringBefore("&")
}
}
override fun mangaDetailsRequest(manga: SManga): Request {
val id = getMangaId(manga)
val appendId = !manga.url.startsWith("{")
return GET("$baseUrl/wp-json/wp/v2/manga/$id?_embed#$appendId", headers)
}
override fun getMangaUrl(manga: SManga): String {
val slug = if (manga.url.startsWith("{")) {
manga.url.parseAs<MangaUrl>().slug
} else {
"$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
}
return "$baseUrl/manga/$slug/"
}
override fun mangaDetailsParse(response: Response): SManga {
val manga = response.parseAs<Manga>(transform = ::transformJsonResponse)
val appendId = response.request.url.fragment == "true"
return manga.toSManga(appendId)
}
override fun chapterListRequest(manga: SManga): Request {
val id = getMangaId(manga)
val url = "$baseUrl/wp-admin/admin-ajax.php".toHttpUrl().newBuilder()
.addQueryParameter("manga_id", id)
.addQueryParameter("page", "${Random.nextInt(99, 9999)}") // keep above 3 for loading hidden chapter
.addQueryParameter("action", "chapter_list")
.build()
return GET(url, headers)
}
protected open val chapterListSelector = "div a:has(time)"
protected open val chapterNameSelector = "span"
protected open val chapterDateSelector = "time"
protected open val chapterDateAttribute = "datetime"
override fun chapterListParse(response: Response): List<SChapter> {
val document = Jsoup.parseBodyFragment(response.body.string(), baseUrl)
return document.select(chapterListSelector).map {
SChapter.create().apply {
setUrlWithoutDomain(it.absUrl("href"))
name = it.selectFirst(chapterNameSelector)!!.ownText()
date_upload = dateFormat.tryParse(
it.selectFirst(chapterDateSelector)?.attr(chapterDateAttribute),
)
}
}
}
protected open val pageListSelector = "main .relative section > img"
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select(pageListSelector).mapIndexed { idx, img ->
Page(idx, imageUrl = img.absUrl("src"))
}
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
protected open fun transformJsonResponse(responseBody: String): String = responseBody
}

View File

@ -1,275 +0,0 @@
package eu.kanade.tachiyomi.multisrc.otakusanctuary
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.select.Elements
import org.jsoup.select.Evaluator
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
open class OtakuSanctuary(
override val name: String,
override val baseUrl: String,
override val lang: String,
) : HttpSource() {
override val supportsLatest = false
override val client = network.cloudflareClient
override fun headersBuilder(): Headers.Builder = super.headersBuilder().add("Referer", "$baseUrl/")
private val helper = OtakuSanctuaryHelper(lang)
private val json: Json by injectLazy()
// There's no popular list, this will have to do
override fun popularMangaRequest(page: Int) = POST(
"$baseUrl/Manga/Newest",
headers,
FormBody.Builder().apply {
add("Lang", helper.otakusanLang())
add("Page", page.toString())
add("Type", "Include")
add("Dir", "NewPostedDate")
}.build(),
)
private fun parseMangaCollection(elements: Elements): List<SManga> {
val page = emptyList<SManga>().toMutableList()
for (element in elements) {
val url = element.select("div.mdl-card__title a").first()!!.attr("abs:href")
// ignore external chapters
if (url.toHttpUrl().host != baseUrl.toHttpUrl().host) {
continue
}
// ignore web novels/light novels
val variant = element.select("div.mdl-card__supporting-text div.text-overflow-90 a").text()
if (variant.contains("Novel")) {
continue
}
// ignore languages that dont match current ext
val language = element.select("img.flag").attr("abs:src")
.substringAfter("flags/")
.substringBefore(".png")
if (helper.otakusanLang() != "all" && language != helper.otakusanLang()) {
continue
}
page += SManga.create().apply {
setUrlWithoutDomain(url)
title = element.select("div.mdl-card__supporting-text a[target=_blank]").text()
.replaceFirstChar { it.titlecase() }
thumbnail_url = element.select("div.container-3-4.background-contain img").first()!!.attr("abs:src")
}
}
return page
}
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val collection = document.select("div.mdl-card")
val hasNextPage = !document.select("button.btn-loadmore").text().contains("Hết")
return MangasPage(parseMangaCollection(collection), hasNextPage)
}
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
GET(
baseUrl.toHttpUrl().newBuilder().apply {
addPathSegments("Home/Search")
addQueryParameter("search", query)
}.build().toString(),
headers,
)
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val collection = document.select("div.collection:has(.group-header:contains(Manga)) div.mdl-card")
return MangasPage(parseMangaCollection(collection), false)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
return SManga.create().apply {
title = document.select("h1.title.text-lg-left.text-overflow-2-line")
.text()
.replaceFirstChar { it.titlecase() }
author = document.select("tr:contains(Tác Giả) a.capitalize").first()!!.text()
.replaceFirstChar { it.titlecase() }
description = document.select("div.summary p").joinToString("\n") {
it.run {
select(Evaluator.Tag("br")).prepend("\\n")
this.text().replace("\\n", "\n").replace("\n ", "\n")
}
}.trim()
genre = document.select("div.genres a").joinToString { it.text() }
thumbnail_url = document.select("div.container-3-4.background-contain img").attr("abs:src")
val statusString = document.select("tr:contains(Tình Trạng) td").first()!!.text().trim()
status = when (statusString) {
"Ongoing" -> SManga.ONGOING
"Done" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
}
}
private val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.US).apply {
timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh")
}
private fun parseDate(date: String): Long {
if (date.contains("cách đây")) {
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance()
return when {
date.contains("ngày") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
date.contains("tiếng") -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
date.contains("phút") -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
date.contains("giây") -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
else -> 0L
}
} else {
return runCatching { dateFormat.parse(date)?.time }.getOrNull() ?: 0L
}
}
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select("tr.chapter").map {
val cells = it.select("td")
SChapter.create().apply {
setUrlWithoutDomain(cells[1].select("a").attr("href"))
name = cells[1].text()
date_upload = parseDate(cells[3].text())
chapter_number = cells[0].text().toFloatOrNull() ?: -1f
}
}
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val vi = document.select("#dataip").attr("value")
val numericId = document.select("#inpit-c").attr("data-chapter-id")
val data = json.parseToJsonElement(
client.newCall(
POST(
"$baseUrl/Manga/UpdateView",
headers,
FormBody.Builder().add("chapId", numericId).build(),
),
).execute().body.string(),
).jsonObject
if (data["view"] != null) {
val usingservers = mutableListOf(0, 0, 0)
val isSuccess = data["isSuccess"]!!.jsonArray.map { it.jsonPrimitive.content }
return json.parseToJsonElement(data["view"]!!.jsonPrimitive.content).jsonArray.mapIndexed { idx, it ->
var url = helper.processUrl(it.jsonPrimitive.content).removePrefix("image:")
val indexServer = getIndexLessServer(usingservers)
if (url.contains("ImageSyncing") || url.contains("FetchService") || url.contains("otakusan.net_") && (url.contains("extendContent") || url.contains("/Extend")) && !url.contains("fetcher.otakusan.net") && !url.contains("image3.otakusan.net") && !url.contains("image3.otakuscan.net") && !url.contains("[GDP]") && !url.contains("[GDT]")) {
if (url.startsWith("/api/Value/")) {
val serverUrl = if (helper.otakusanLang() == "us" && indexServer == 1) {
US_SERVERS[0]
} else {
SERVERS[indexServer]
}
url = "$serverUrl$url"
}
if (url.contains("otakusan.net_") && !url.contains("fetcher.otakuscan.net")) {
url += "#${isSuccess[idx]}"
}
usingservers[indexServer] += 1
}
Page(idx, imageUrl = url)
}
} else {
val alternate = json.parseToJsonElement(
client.newCall(
POST(
"$baseUrl/Manga/CheckingAlternate",
headers,
FormBody.Builder().add("chapId", numericId).build(),
),
).execute().body.string(),
).jsonObject
val content = alternate["Content"]?.jsonPrimitive?.content
?: throw Exception("No pages found")
return json.parseToJsonElement(content).jsonArray.mapIndexed { idx, it ->
Page(idx, imageUrl = helper.processUrl(it.jsonPrimitive.content, vi))
}
}
}
override fun imageRequest(page: Page): Request {
val request = super.imageRequest(page)
val url = request.url.toString()
val newRequest = request.newBuilder()
if (url.contains("ImageSyncing") || url.contains("FetchService") || url.contains("otakusan.net_") && (url.contains("extendContent") || url.contains("/Extend")) && !url.contains("fetcher.otakusan.net") && !url.contains("image3.otakusan.net") && !url.contains("image3.otakuscan.net") && !url.contains("[GDP]") && !url.contains("[GDT]")) {
if (url.contains("otakusan.net_") && !url.contains("fetcher.otakuscan.net")) {
newRequest.header("page-sign", request.url.fragment!!)
} else {
newRequest.header("page-lang", "vn-lang")
}
}
return newRequest.build()
}
private fun getIndexLessServer(usingservers: List<Int>): Int {
var minIndex = usingservers[0]
var minNumber = usingservers[0]
for (i in 1 until 3) {
if (usingservers[i] <= minNumber) {
minIndex = i
minNumber = usingservers[i]
}
}
return minIndex
}
companion object {
val SERVERS = listOf("https://image2.otakuscan.net", "https://shopotaku.net", "https://image.otakuscan.net")
val US_SERVERS = listOf("https://image3.shopotaku.net", "https://image2.otakuscan.net")
}
}

View File

@ -1,158 +0,0 @@
package eu.kanade.tachiyomi.multisrc.otakusanctuary
import okhttp3.HttpUrl.Companion.toHttpUrl
class OtakuSanctuaryHelper(private val lang: String) {
fun otakusanLang() = when (lang) {
"vi" -> "vn"
"en" -> "us"
else -> lang
}
fun processUrl(url: String, vi: String = ""): String {
var url = url.replace("_h_", "http")
.replace("_e_", "/extendContent/Manga")
.replace("_r_", "/extendContent/MangaRaw")
if (url.startsWith("//")) {
url = "https:$url"
}
if (url.contains("drive.google.com")) {
return url
}
url = when (url.slice(0..4)) {
"[GDP]" -> url.replace("[GDP]", "https://drive.google.com/uc?export=view&id=")
"[GDT]" -> if (otakusanLang() == "us") {
url.replace("image2.otakuscan.net", "image3.shopotaku.net")
.replace("image2.otakusan.net", "image3.shopotaku.net")
} else {
url
}
"[IS1]" -> {
val url = url.replace("[IS1]", "https://imagepi.otakuscan.net/")
if (url.contains("vi") && url.contains("otakusan.net_")) {
url
} else {
url.toHttpUrl().newBuilder().apply {
addQueryParameter("vi", vi)
}.build().toString()
}
}
"[IS3]" -> url.replace("[IS3]", "https://image3.otakusan.net/")
"[IO3]" -> url.replace("[IO3]", "http://image3.shopotaku.net/")
else -> url
}
if (url.contains("/Content/Workshop") || url.contains("otakusan") || url.contains("myrockmanga")) {
return url
}
if (url.contains("file-bato-orig.anyacg.co")) {
url = url.replace("file-bato-orig.anyacg.co", "file-bato-orig.bato.to")
}
if (url.contains("file-comic")) {
if (url.contains("file-comic-1")) {
url = url.replace("file-comic-1.anyacg.co", "z-img-01.mangapark.net")
}
if (url.contains("file-comic-2")) {
url = url.replace("file-comic-2.anyacg.co", "z-img-02.mangapark.net")
}
if (url.contains("file-comic-3")) {
url = url.replace("file-comic-3.anyacg.co", "z-img-03.mangapark.net")
}
if (url.contains("file-comic-4")) {
url = url.replace("file-comic-4.anyacg.co", "z-img-04.mangapark.net")
}
if (url.contains("file-comic-5")) {
url = url.replace("file-comic-5.anyacg.co", "z-img-05.mangapark.net")
}
if (url.contains("file-comic-6")) {
url = url.replace("file-comic-6.anyacg.co", "z-img-06.mangapark.net")
}
if (url.contains("file-comic-9")) {
url = url.replace("file-comic-9.anyacg.co", "z-img-09.mangapark.net")
}
if (url.contains("file-comic-10")) {
url = url.replace("file-comic-10.anyacg.co", "z-img-10.mangapark.net")
}
if (url.contains("file-comic-99")) {
url = url.replace("file-comic-99.anyacg.co/uploads", "file-bato-0001.bato.to")
}
}
if (url.contains("cdn.nettruyen.com")) {
url = url.replace(
"cdn.nettruyen.com/Data/Images/",
"truyen.cloud/data/images/",
)
}
if (url.contains("url=")) {
url = url.substringAfter("url=")
}
if (url.contains("blogspot") || url.contains("fshare")) {
url = url.replace("http:", "https:")
}
if (url.contains("blogspot") && !url.contains("http")) {
url = "https://$url"
}
if (url.contains("app/manga/uploads/") && !url.contains("http")) {
url = "https://lhscan.net$url"
}
url = url.replace("//cdn.adtrue.com/rtb/async.js", "")
if (url.contains(".webp")) {
url = "https://otakusan.net/api/Value/ImageSyncing?ip=34512351".toHttpUrl().newBuilder()
.apply {
addQueryParameter("url", url)
}.build().toString()
} else if (
(
url.contains("merakiscans") ||
url.contains("mangazuki") ||
url.contains("ninjascans") ||
url.contains("anyacg.co") ||
url.contains("mangakatana") ||
url.contains("zeroscans") ||
url.contains("mangapark") ||
url.contains("mangadex") ||
url.contains("uptruyen") ||
url.contains("hocvientruyentranh") ||
url.contains("ntruyen.info") ||
url.contains("chancanvas") ||
url.contains("bato.to")
) &&
(
!url.contains("googleusercontent") &&
!url.contains("otakusan") &&
!url.contains("otakuscan") &&
!url.contains("shopotaku")
)
) {
url =
"https://images2-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&gadget=a&no_expand=1&resize_h=0&rewriteMime=image%2F*".toHttpUrl()
.newBuilder().apply {
addQueryParameter("url", url)
}.build().toString()
} else if (url.contains("imageinstant.com")) {
url = "https://images.weserv.nl/".toHttpUrl().newBuilder().apply {
addQueryParameter("url", url)
}.build().toString()
} else if (!url.contains("otakusan.net")) {
url = "https://otakusan.net/api/Value/ImageSyncing?ip=34512351".toHttpUrl().newBuilder()
.apply {
addQueryParameter("url", url)
}.build().toString()
}
return if (url.contains("vi=") && !url.contains("otakusan.net_")) {
url
} else {
url.toHttpUrl().newBuilder().apply {
addQueryParameter("vi", vi)
}.build().toString()
}
}
}

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

View File

@ -0,0 +1,182 @@
package eu.kanade.tachiyomi.multisrc.scanr
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import kotlin.collections.iterator
abstract class ScanR(
override val name: String,
override val baseUrl: String,
final override val lang: String,
private val useHighLowQualityCover: Boolean = false,
private val slugSeparator: String = "-",
) : HttpSource() {
companion object {
private const val SERIES_DATA_SELECTOR = "#series-data-placeholder"
}
override val supportsLatest = false
private val seriesDataCache = mutableMapOf<String, SeriesData>()
// Popular
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/data/config.json", headers)
}
override fun popularMangaParse(response: Response): MangasPage {
return searchMangaParse(response)
}
// Latest
override fun latestUpdatesRequest(page: Int): Request {
throw UnsupportedOperationException()
}
override fun latestUpdatesParse(response: Response): MangasPage {
throw UnsupportedOperationException()
}
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = if (query.isNotBlank()) {
"$baseUrl/data/config.json#$query"
} else {
"$baseUrl/data/config.json"
}
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val config = response.parseAs<ConfigResponse>()
val mangaList = mutableListOf<SManga>()
val fragment = response.request.url.fragment
val searchQuery = fragment ?: ""
for (fileName in config.localSeriesFiles) {
val seriesData = fetchSeriesData(fileName)
if (searchQuery.isBlank() || seriesData.title.contains(
searchQuery,
ignoreCase = true,
)
) {
mangaList.add(seriesData.toSManga(useHighLowQualityCover, slugSeparator))
}
}
return MangasPage(mangaList, false)
}
// Details
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
val seriesData = jsonData.parseAs<SeriesData>()
return seriesData.toDetailedSManga(useHighLowQualityCover, slugSeparator)
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val chapterNumber = document.location().substringAfterLast("/")
val chapterId = extractChapterId(document, chapterNumber)
return fetchChapterPages(chapterId)
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
val jsonData = document.selectFirst(SERIES_DATA_SELECTOR)!!.html()
val seriesData = jsonData.parseAs<SeriesData>()
return buildChapterList(seriesData)
}
private fun fetchSeriesData(fileName: String): SeriesData {
val cachedData = seriesDataCache[fileName]
if (cachedData != null) {
return cachedData
}
val fileUrl = "$baseUrl/data/series/$fileName"
val response = client.newCall(GET(fileUrl, headers)).execute()
val seriesData = response.parseAs<SeriesData>()
seriesDataCache[fileName] = seriesData
return seriesData
}
private fun extractChapterId(document: Document, chapterNumber: String): String {
val jsonData = document.selectFirst("#reader-data-placeholder")!!.html()
val readerData = jsonData.parseAs<ReaderData>()
return readerData.series.chapters
?.get(chapterNumber)
?.groups
?.values
?.firstOrNull()
?.substringAfterLast("/")
?: throw NoSuchElementException("Chapter data not found for chapter $chapterNumber")
}
private fun buildChapterList(seriesData: SeriesData): List<SChapter> {
val chapters = seriesData.chapters ?: return emptyList()
val chapterList = mutableListOf<SChapter>()
val multipleChapters = chapters.size > 1
for ((chapterNumber, chapterData) in chapters) {
if (chapterData.licencied) continue
val title = chapterData.title ?: ""
val volumeNumber = chapterData.volume ?: ""
val baseName = if (multipleChapters) {
buildString {
if (volumeNumber.isNotBlank()) append("Vol. $volumeNumber ")
append("Ch. $chapterNumber")
if (title.isNotBlank()) append(" $title")
}
} else {
if (title.isNotBlank()) "One Shot $title" else "One Shot"
}
val chapter = SChapter.create().apply {
name = baseName
url = "/${toSlug(seriesData.title)}/$chapterNumber"
chapter_number = chapterNumber.toFloatOrNull() ?: -1f
date_upload = chapterData.lastUpdated * 1000L
}
chapterList.add(chapter)
}
return chapterList.sortedByDescending { it.chapter_number }
}
private fun fetchChapterPages(chapterId: String): List<Page> {
val pagesResponse =
client.newCall(GET("$baseUrl/api/imgchest-chapter-pages?id=$chapterId", headers))
.execute()
val pages = pagesResponse.parseAs<List<PageData>>()
return pages.mapIndexed { index, pageData ->
Page(index, imageUrl = pageData.link)
}
}
}

View File

@ -0,0 +1,157 @@
package eu.kanade.tachiyomi.multisrc.scanr
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
object SafeLongDeserializer : KSerializer<Long> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("SafeLong", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Long) {
encoder.encodeLong(value)
}
override fun deserialize(decoder: Decoder): Long {
val jsonDecoder = decoder as? JsonDecoder ?: return try {
decoder.decodeLong()
} catch (_: Exception) {
0L
}
return try {
val element = jsonDecoder.decodeJsonElement()
when (element) {
is JsonPrimitive -> {
element.longOrNull ?: element.content.toLongOrNull() ?: 0L
}
else -> 0L
}
} catch (_: Exception) {
0L
}
}
}
@Serializable
data class ConfigResponse(
@SerialName("LOCAL_SERIES_FILES")
val localSeriesFiles: List<String>,
)
@Serializable
data class SeriesData(
val title: String,
val description: String?,
val artist: String?,
val author: String?,
val cover: String?,
@SerialName("cover_low")
val coverLow: String?,
@SerialName("cover_hq")
val coverHq: String?,
val tags: List<String>?,
@SerialName("release_status")
val releaseStatus: String?,
@SerialName("alternative_titles")
val alternativeTitles: List<String>?,
val chapters: Map<String, ChapterData>?,
)
@Serializable
data class ReaderData(
val series: SeriesData,
)
@Serializable
data class ChapterData(
val title: String?,
val volume: String?,
@SerialName("last_updated")
@Serializable(with = SafeLongDeserializer::class)
val lastUpdated: Long = 0L,
val licencied: Boolean = false,
val groups: Map<String, String>?,
)
@Serializable
data class PageData(
val link: String,
)
// DTO to SManga extension functions
fun SeriesData.toSManga(useLowQuality: Boolean = false, slugSeparator: String): SManga =
SManga.create().apply {
title = this@toSManga.title
artist = this@toSManga.artist
author = this@toSManga.author
thumbnail_url = if (useLowQuality) this@toSManga.coverHq else this@toSManga.cover
url = "/${toSlug(this@toSManga.title, slugSeparator)}"
}
fun SeriesData.toDetailedSManga(useHighQuality: Boolean = false, slugSeparator: String): SManga =
SManga.create().apply {
title = this@toDetailedSManga.title
val baseDescription = this@toDetailedSManga.description.let {
if (it?.contains("Pas de synopsis", ignoreCase = true) == true) null else it
}
val altTitles = this@toDetailedSManga.alternativeTitles
description = if (!altTitles.isNullOrEmpty()) {
buildString {
if (!baseDescription.isNullOrBlank()) {
append(baseDescription)
append("\n\n")
}
append("Alternative Titles:\n")
append(altTitles.joinToString("\n") { "$it" })
}
} else {
baseDescription
}
artist = this@toDetailedSManga.artist
author = this@toDetailedSManga.author
genre = this@toDetailedSManga.tags?.joinToString(", ") ?: ""
status = when (this@toDetailedSManga.releaseStatus) {
"En cours" -> SManga.ONGOING
"Finis", "Fini" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
thumbnail_url =
if (useHighQuality) this@toDetailedSManga.coverHq else this@toDetailedSManga.cover
url = "/${toSlug(this@toDetailedSManga.title, slugSeparator)}"
}
// Utility function for slug generation
// URLs are manually calculated using a slugify function
fun toSlug(input: String?, slugSeparator: String = "-"): String {
if (input == null) return ""
val accentsMap = mapOf(
'à' to 'a', 'á' to 'a', 'â' to 'a', 'ä' to 'a', 'ã' to 'a',
'è' to 'e', 'é' to 'e', 'ê' to 'e', 'ë' to 'e',
'ì' to 'i', 'í' to 'i', 'î' to 'i', 'ï' to 'i',
'ò' to 'o', 'ó' to 'o', 'ô' to 'o', 'ö' to 'o', 'õ' to 'o',
'ù' to 'u', 'ú' to 'u', 'û' to 'u', 'ü' to 'u',
'ç' to 'c', 'ñ' to 'n',
)
return input
.lowercase()
.map { accentsMap[it] ?: it }
.joinToString("")
.replace("[^a-z0-9\\s-]".toRegex(), "")
.replace("\\s".toRegex(), slugSeparator)
}

View File

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View File

@ -0,0 +1,171 @@
package eu.kanade.tachiyomi.lib.clipstudioreader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.parser.Parser
abstract class ClipStudioReader : HttpSource() {
override val client = super.client.newBuilder()
.addInterceptor(Deobfuscator())
.addInterceptor(ImageInterceptor())
.build()
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
override fun pageListParse(response: Response): List<Page> {
val requestUrl = response.request.url
val contentId = requestUrl.queryParameter("c")
if (contentId != null) {
// EPUB-based path
val tokenUrl = "$baseUrl/api/tokens/viewer?content_id=$contentId".toHttpUrl()
val tokenResponse = client.newCall(GET(tokenUrl, headers)).execute()
val viewerToken = tokenResponse.parseAs<TokenResponse>().token
val metaUrl = "$baseUrl/api/contents/$contentId/meta".toHttpUrl()
val apiHeaders = headersBuilder().add("Authorization", "Bearer $viewerToken").build()
val metaResponse = client.newCall(GET(metaUrl, apiHeaders)).execute()
val contentBaseUrl = metaResponse.parseAs<MetaResponse>().content.baseUrl
val preprocessUrl = "$contentBaseUrl/preprocess-settings.json"
val obfuscationResponse = client.newCall(GET(preprocessUrl, headers)).execute()
val obfuscationKey = obfuscationResponse.parseAs<PreprocessSettings>().obfuscateImageKey
val containerUrl = "$contentBaseUrl/META-INF/container.xml"
val containerResponse = client.newCall(GET(containerUrl, headers)).execute()
val containerDoc = Jsoup.parse(containerResponse.body.string(), containerUrl, Parser.xmlParser())
val opfPath = containerDoc.selectFirst("*|rootfile")?.attr("full-path")
?: throw Exception("Failed to find rootfile in container.xml")
val opfUrl = (contentBaseUrl.removeSuffix("/") + "/" + opfPath).toHttpUrl()
val opfResponse = client.newCall(GET(opfUrl, headers)).execute()
val opfDoc = opfResponse.asJsoup()
val imageManifestItems = opfDoc.select("*|item[media-type^=image/]")
.sortedBy { it.attr("href") }
if (imageManifestItems.isEmpty()) {
throw Exception("No image pages found in the EPUB manifest")
}
return imageManifestItems.mapIndexed { i, item ->
val href = item.attr("href")
?: throw Exception("Image item found with no href")
val imageUrlBuilder = opfUrl.resolve(href)!!.newBuilder()
obfuscationKey.let {
imageUrlBuilder.addQueryParameter("obfuscateKey", it.toString())
}
Page(i, imageUrl = imageUrlBuilder.build().toString())
}
}
// param/cgi-based XML path
// param/cgi in URL
var authkey = requestUrl.queryParameter("param")?.replace(" ", "+")
var endpoint = requestUrl.queryParameter("cgi")
// param/cgi in HTML
if (authkey.isNullOrEmpty() || endpoint.isNullOrEmpty()) {
val document = response.asJsoup()
authkey = document.selectFirst("div#meta input[name=param]")?.attr("value")
?: throw Exception("Could not find auth key")
endpoint = document.selectFirst("div#meta input[name=cgi]")?.attr("value")
?: throw Exception("Could not find endpoint")
}
val viewerUrl = baseUrl.toHttpUrl().resolve(endpoint)
?: throw Exception("Could not resolve endpoint URL: $endpoint")
val faceUrl = viewerUrl.newBuilder().apply {
addQueryParameter("mode", MODE_DL_FACE_XML)
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
addQueryParameter("vm", "4")
addQueryParameter("file", "face.xml")
addQueryParameter("param", authkey)
}.build()
val faceResponse = client.newCall(GET(faceUrl, headers)).execute()
if (!faceResponse.isSuccessful) throw Exception("HTTP error ${faceResponse.code} while fetching face.xml")
val faceData = faceResponse.use { parseFaceData(it.asJsoup()) }
return (0 until faceData.totalPages).map { i ->
val pageFileName = i.toString().padStart(4, '0') + ".xml"
val pageXmlUrl = viewerUrl.newBuilder().apply {
addQueryParameter("mode", MODE_DL_PAGE_XML)
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
addQueryParameter("vm", "4")
addQueryParameter("file", pageFileName)
addQueryParameter("param", authkey)
// Custom params
addQueryParameter("csr_sw", faceData.scrambleWidth.toString())
addQueryParameter("csr_sh", faceData.scrambleHeight.toString())
}.build()
Page(i, url = pageXmlUrl.toString())
}
}
override fun imageUrlParse(response: Response): String {
val requestUrl = response.request.url
val document = response.asJsoup()
val authkey = requestUrl.queryParameter("param")!!
val scrambleGridW = requestUrl.queryParameter("csr_sw")!!
val scrambleGridH = requestUrl.queryParameter("csr_sh")!!
// Reconstruct endpoint without query params
val endpointUrl = requestUrl.newBuilder().query(null).build()
val pageIndex = document.selectFirst("PageNo")?.text()?.toIntOrNull()
?: throw Exception("Could not find PageNo")
val scrambleArray = document.selectFirst("Scramble")?.text()
val parts = document.select("Kind").mapNotNull {
val type = it.text().toIntOrNull()
val number = it.attr("No")
val isScrambled = it.attr("scramble") == "1"
if (type == null || number.isEmpty()) return@mapNotNull null
val partFileName = "${pageIndex.toString().padStart(4, '0')}_${number.padStart(4, '0')}.bin"
PagePart(partFileName, type, isScrambled)
}
val imagePart = parts.firstOrNull { it.type in SUPPORTED_IMAGE_TYPES }
?: throw Exception("No supported image parts found for page")
val imageUrlBuilder = endpointUrl.newBuilder().apply {
addQueryParameter("mode", imagePart.type.toString())
addQueryParameter("file", imagePart.fileName)
addQueryParameter("reqtype", REQUEST_TYPE_FILE)
addQueryParameter("param", authkey)
}
if (imagePart.isScrambled && !scrambleArray.isNullOrEmpty()) {
imageUrlBuilder.apply {
addQueryParameter("scrambleArray", scrambleArray)
addQueryParameter("scrambleGridW", scrambleGridW)
addQueryParameter("scrambleGridH", scrambleGridH)
}
}
return imageUrlBuilder.build().toString()
}
private fun parseFaceData(document: Document): FaceData {
val totalPages = document.selectFirst("TotalPage")?.text()?.toIntOrNull()
val scrambleWidth = document.selectFirst("Scramble > Width")?.text()?.toIntOrNull()
val scrambleHeight = document.selectFirst("Scramble > Height")?.text()?.toIntOrNull()
return FaceData(totalPages!!, scrambleWidth!!, scrambleHeight!!)
}
companion object {
private const val MODE_DL_FACE_XML = "7"
private const val MODE_DL_PAGE_XML = "8"
private const val REQUEST_TYPE_FILE = "0"
private val SUPPORTED_IMAGE_TYPES = setOf(1, 2, 3) // JPEG, GIF, PNG
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.lib.clipstudioreader
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
class Deobfuscator : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val keyStr = request.url.queryParameter("obfuscateKey")
if (keyStr.isNullOrEmpty()) {
return chain.proceed(request)
}
val key = keyStr.toInt()
val newUrl = request.url.newBuilder().removeAllQueryParameters("obfuscateKey").build()
val newRequest = request.newBuilder().url(newUrl).build()
val response = chain.proceed(newRequest)
if (!response.isSuccessful) {
return response
}
val obfuscatedBytes = response.body.bytes()
val deobfuscatedBytes = deobfuscate(obfuscatedBytes, key)
val body = deobfuscatedBytes.toResponseBody("image/jpeg".toMediaType())
return response.newBuilder().body(body).build()
}
private fun deobfuscate(bytes: ByteArray, key: Int): ByteArray {
val limit = minOf(bytes.size, 1024)
for (i in 0 until limit) {
bytes[i] = (bytes[i].toInt() xor key).toByte()
}
return bytes
}
}

View File

@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.lib.clipstudioreader
import kotlinx.serialization.Serializable
// XML
class FaceData(
val totalPages: Int,
val scrambleWidth: Int,
val scrambleHeight: Int,
)
class PagePart(
val fileName: String,
val type: Int,
val isScrambled: Boolean,
)
// EPUB
@Serializable
class TokenResponse(
val token: String,
)
@Serializable
class MetaResponse(
val content: MetaContent,
)
@Serializable
class MetaContent(
val baseUrl: String,
)
@Serializable
class PreprocessSettings(
val obfuscateImageKey: Int,
)

View File

@ -0,0 +1,81 @@
package eu.kanade.tachiyomi.lib.clipstudioreader
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Rect
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.ByteArrayOutputStream
import kotlin.math.floor
class ImageInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url
val scrambleArray = url.queryParameter("scrambleArray")
val scrambleGridW = url.queryParameter("scrambleGridW")?.toIntOrNull()
val scrambleGridH = url.queryParameter("scrambleGridH")?.toIntOrNull()
if (scrambleArray.isNullOrEmpty() || scrambleGridW == null || scrambleGridH == null) {
return chain.proceed(request)
}
val newUrl = url.newBuilder()
.removeAllQueryParameters("scrambleArray")
.removeAllQueryParameters("scrambleGridW")
.removeAllQueryParameters("scrambleGridH")
.build()
val newRequest = request.newBuilder().url(newUrl).build()
val response = chain.proceed(newRequest)
if (!response.isSuccessful) {
return response
}
val scrambleMapping = scrambleArray.split(',').map { it.toInt() }
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
val descrambledImg = unscrambleImage(scrambledImg, scrambleMapping, scrambleGridW, scrambleGridH)
val output = ByteArrayOutputStream()
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
val body = output.toByteArray().toResponseBody("image/jpeg".toMediaType())
return response.newBuilder().body(body).build()
}
private fun unscrambleImage(
image: Bitmap,
scrambleMapping: List<Int>,
gridWidth: Int,
gridHeight: Int,
): Bitmap {
val descrambledImg = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(descrambledImg)
val pieceWidth = 8 * floor(floor(image.width.toFloat() / gridWidth) / 8).toInt()
val pieceHeight = 8 * floor(floor(image.height.toFloat() / gridHeight) / 8).toInt()
if (scrambleMapping.size < gridWidth * gridHeight || image.width < 8 * gridWidth || image.height < 8 * gridHeight) {
return image
}
for (scrambleIndex in scrambleMapping.indices) {
val destX = scrambleIndex % gridWidth * pieceWidth
val destY = floor(scrambleIndex.toFloat() / gridWidth).toInt() * pieceHeight
val destRect = Rect(destX, destY, destX + pieceWidth, destY + pieceHeight)
val sourcePieceIndex = scrambleMapping[scrambleIndex]
val sourceX = sourcePieceIndex % gridWidth * pieceWidth
val sourceY = floor(sourcePieceIndex.toFloat() / gridWidth).toInt() * pieceHeight
val sourceRect = Rect(sourceX, sourceY, sourceX + pieceWidth, sourceY + pieceHeight)
canvas.drawBitmap(image, sourceRect, destRect, null)
}
return descrambledImg
}
}

View File

@ -1,7 +1,7 @@
ext {
extName = 'Bato.to'
extClass = '.BatoToFactory'
extVersionCode = 53
extVersionCode = 56
isNsfw = true
}

View File

@ -2,7 +2,12 @@ package eu.kanade.tachiyomi.extension.all.batoto
import android.app.Application
import android.content.SharedPreferences
import android.text.Editable
import android.text.TextWatcher
import android.widget.Button
import android.widget.Toast
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.BuildConfig
@ -54,15 +59,7 @@ open class BatoTo(
override val name: String = "Bato.to"
override var baseUrl: String = ""
get() {
val current = field
if (current.isNotEmpty()) {
return current
}
field = getMirrorPref()
return field
}
override val baseUrl: String get() = getMirrorPref()
override val id: Long = when (lang) {
"zh-Hans" -> 2818874445640189582
@ -79,10 +76,6 @@ open class BatoTo(
entryValues = MIRROR_PREF_ENTRY_VALUES
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
baseUrl = newValue as String
true
}
}
val altChapterListPref = CheckBoxPreference(screen.context).apply {
key = "${ALT_CHAPTER_LIST_PREF_KEY}_$lang"
@ -99,9 +92,49 @@ open class BatoTo(
"You might also want to clear the database in advanced settings."
setDefaultValue(false)
}
val removeCustomPref = EditTextPreference(screen.context).apply {
key = "${REMOVE_TITLE_CUSTOM_PREF}_$lang"
title = "Custom regex to be removed from title"
summary = customRemoveTitle()
setDefaultValue("")
val validate = { str: String ->
runCatching { Regex(str) }
.map { true to "" }
.getOrElse { false to it.message }
}
setOnBindEditTextListener { editText ->
editText.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun afterTextChanged(editable: Editable?) {
editable ?: return
val text = editable.toString()
val valid = validate(text)
editText.error = if (!valid.first) valid.second else null
editText.rootView.findViewById<Button>(android.R.id.button1)?.isEnabled = editText.error == null
}
},
)
}
setOnPreferenceChangeListener { _, newValue ->
val (isValid, message) = validate(newValue as String)
if (isValid) {
summary = newValue
} else {
Toast.makeText(screen.context, message, Toast.LENGTH_LONG).show()
}
isValid
}
}
screen.addPreference(mirrorPref)
screen.addPreference(altChapterListPref)
screen.addPreference(removeOfficialPref)
screen.addPreference(removeCustomPref)
}
private fun getMirrorPref(): String {
@ -132,12 +165,14 @@ open class BatoTo(
private fun isRemoveTitleVersion(): Boolean {
return preferences.getBoolean("${REMOVE_TITLE_VERSION_PREF}_$lang", false)
}
private fun customRemoveTitle(): String =
preferences.getString("${REMOVE_TITLE_CUSTOM_PREF}_$lang", "")!!
private fun SharedPreferences.migrateMirrorPref() {
val selectedMirror = getString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE)!!
if (selectedMirror in DEPRECATED_MIRRORS) {
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).commit()
edit().putString("${MIRROR_PREF_KEY}_$lang", MIRROR_PREF_DEFAULT_VALUE).apply()
}
}
@ -161,8 +196,9 @@ open class BatoTo(
val manga = SManga.create()
val item = element.select("a.item-cover")
val imgurl = item.select("img").attr("abs:src")
manga.setUrlWithoutDomain(item.attr("href"))
manga.setUrlWithoutDomain(stripSeriesUrl(item.attr("href")))
manga.title = element.select("a.item-title").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = imgurl
return manga
}
@ -287,9 +323,10 @@ open class BatoTo(
val infoElement = document.select("div#mainer div.container-fluid")
val manga = SManga.create()
manga.title = infoElement.select("h3").text().removeEntities()
.cleanTitleIfNeeded()
manga.thumbnail_url = document.select("div.attr-cover img")
.attr("abs:src")
manga.setUrlWithoutDomain(infoElement.select("h3 a").attr("abs:href"))
manga.setUrlWithoutDomain(stripSeriesUrl(infoElement.select("h3 a").attr("abs:href")))
return MangasPage(listOf(manga), false)
}
@ -320,16 +357,18 @@ open class BatoTo(
private fun searchUtilsFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.select("td a").attr("href"))
manga.setUrlWithoutDomain(stripSeriesUrl(element.select("td a").attr("href")))
manga.title = element.select("td a").text()
.cleanTitleIfNeeded()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
private fun searchHistoryFromElement(element: Element): SManga {
val manga = SManga.create()
manga.setUrlWithoutDomain(element.select(".position-relative a").attr("href"))
manga.setUrlWithoutDomain(stripSeriesUrl(element.select(".position-relative a").attr("href")))
manga.title = element.select(".position-relative a").text()
.cleanTitleIfNeeded()
manga.thumbnail_url = element.select("img").attr("abs:src")
return manga
}
@ -358,8 +397,6 @@ open class BatoTo(
}
return super.mangaDetailsRequest(manga)
}
private var titleRegex: Regex =
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|𖤍.+?𖤍|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|\\/Official|\\/ Official", RegexOption.IGNORE_CASE)
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.selectFirst("div#mainer div.container-fluid")!!
@ -380,19 +417,19 @@ open class BatoTo(
append(it.text().split('/').joinToString("\n") { "${it.trim()}" })
}
}.trim()
val cleanedTitle = if (isRemoveTitleVersion()) {
originalTitle.replace(titleRegex, "").trim()
} else {
originalTitle
}
val cleanedTitle = originalTitle.cleanTitleIfNeeded()
manga.title = cleanedTitle
manga.author = infoElement.select("div.attr-item:contains(author) span").text()
manga.artist = infoElement.select("div.attr-item:contains(artist) span").text()
manga.status = parseStatus(workStatus, uploadStatus)
manga.genre = infoElement.select(".attr-item b:contains(genres) + span ").joinToString { it.text() }
manga.description = description
manga.description = if (originalTitle.trim() != cleanedTitle) {
listOf(originalTitle, description)
.joinToString("\n\n")
} else {
description
}
manga.thumbnail_url = document.select("div.attr-cover img").attr("abs:src")
return manga
}
@ -436,9 +473,9 @@ open class BatoTo(
}
override fun chapterListRequest(manga: SManga): Request {
return if (getAltChapterListPref()) {
val id = manga.url.substringBeforeLast("/").substringAfterLast("/").trim()
val id = seriesIdRegex.find(manga.url)
?.groups?.get(1)?.value?.trim()
return if (getAltChapterListPref() && !id.isNullOrBlank()) {
GET("$baseUrl/rss/series/$id.xml", headers)
} else if (manga.url.startsWith("http")) {
// Check if trying to use a deprecated mirror, force current mirror
@ -583,6 +620,19 @@ open class BatoTo(
private fun String.removeEntities(): String = Parser.unescapeEntities(this, true)
private fun String.cleanTitleIfNeeded(): String {
var tempTitle = this
customRemoveTitle().takeIf { it.isNotEmpty() }?.let { customRegex ->
runCatching {
tempTitle = tempTitle.replace(Regex(customRegex), "")
}
}
if (isRemoveTitleVersion()) {
tempTitle = tempTitle.replace(titleRegex, "")
}
return tempTitle.trim()
}
override fun getFilterList() = FilterList(
LetterFilter(getLetterFilter(), 0),
Filter.Separator(),
@ -1038,50 +1088,94 @@ open class BatoTo(
CheckboxFilterOption("pt-PT", "Portuguese (Portugal)"),
).filterNot { it.value == siteLang }
private fun stripSeriesUrl(url: String): String {
val matchResult = seriesUrlRegex.find(url)
return matchResult?.groups?.get(1)?.value ?: url
}
companion object {
private val seriesUrlRegex = Regex("""(.*/series/\d+)/.*""")
private val seriesIdRegex = Regex("""series/(\d+)""")
private const val MIRROR_PREF_KEY = "MIRROR"
private const val MIRROR_PREF_TITLE = "Mirror"
private const val REMOVE_TITLE_VERSION_PREF = "REMOVE_TITLE_VERSION"
private const val REMOVE_TITLE_CUSTOM_PREF = "REMOVE_TITLE_CUSTOM"
private val MIRROR_PREF_ENTRIES = arrayOf(
"Auto",
"batocomic.com",
"batocomic.net",
"batocomic.org",
// https://batotomirrors.pages.dev/
"ato.to",
"dto.to",
"fto.to",
"hto.to",
"jto.to",
"lto.to",
"mto.to",
"nto.to",
"vto.to",
"wto.to",
"xto.to",
"yto.to",
"vba.to",
"wba.to",
"xba.to",
"yba.to",
"zba.to",
"bato.ac",
"bato.bz",
"bato.cc",
"bato.cx",
"bato.id",
"bato.pw",
"bato.sh",
"bato.to",
"bato.vc",
"bato.day",
"bato.red",
"bato.run",
"batoto.in",
"batoto.tv",
"batotoo.com",
"batotwo.com",
"batpub.com",
"batread.com",
"battwo.com",
"comiko.net",
"comiko.org",
"readtoto.com",
"readtoto.net",
"readtoto.org",
"xbato.com",
"xbato.net",
"xbato.org",
"zbato.com",
"zbato.net",
"zbato.org",
"dto.to",
"fto.to",
"hto.to",
"jto.to",
"mto.to",
"wto.to",
"comiko.net",
"comiko.org",
"mangatoto.com",
"mangatoto.net",
"mangatoto.org",
"batocomic.com",
"batocomic.net",
"batocomic.org",
"readtoto.com",
"readtoto.net",
"readtoto.org",
"kuku.to",
"okok.to",
"ruru.to",
"xdxd.to",
// "bato.si", // (v4)
// "bato.ing", // (v4)
)
private val MIRROR_PREF_ENTRY_VALUES = MIRROR_PREF_ENTRIES.map { "https://$it" }.toTypedArray()
private val MIRROR_PREF_DEFAULT_VALUE = MIRROR_PREF_ENTRY_VALUES[0]
private val DEPRECATED_MIRRORS = listOf(
"https://bato.to",
"https://batocc.com", // parked
"https://mangatoto.com",
"https://mangatoto.net",
"https://mangatoto.org",
)
private const val ALT_CHAPTER_LIST_PREF_KEY = "ALT_CHAPTER_LIST"
private const val ALT_CHAPTER_LIST_PREF_TITLE = "Alternative Chapter List"
private const val ALT_CHAPTER_LIST_PREF_SUMMARY = "If checked, uses an alternate chapter list"
private const val ALT_CHAPTER_LIST_PREF_DEFAULT_VALUE = false
private val titleRegex: Regex =
Regex("\\([^()]*\\)|\\{[^{}]*\\}|\\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|\uD81A\uDD0D.+?\uD81A\uDD0D|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|/Official|/ Official", RegexOption.IGNORE_CASE)
}
}

View File

@ -1,8 +1,9 @@
ext {
extName = 'Comic Growl'
extClass = '.ComicGrowl'
themePkg = 'comiciviewer'
baseUrl = 'https://comic-growl.com'
extVersionCode = 7
overrideVersionCode = 7
isNsfw = false
}

View File

@ -1,212 +1,9 @@
package eu.kanade.tachiyomi.extension.all.comicgrowl
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.Locale
import eu.kanade.tachiyomi.multisrc.comiciviewer.ComiciViewer
class ComicGrowl(
override val lang: String = "all",
override val baseUrl: String = "https://comic-growl.com",
override val name: String = "コミックグロウル",
override val supportsLatest: Boolean = true,
) : ParsedHttpSource() {
override val client = super.client.newBuilder()
.addNetworkInterceptor(ImageDescrambler::interceptor)
.build()
override fun headersBuilder(): Headers.Builder {
return super.headersBuilder().set("Referer", "$baseUrl/")
}
override fun popularMangaRequest(page: Int) = GET("$baseUrl/ranking/manga", headers)
override fun popularMangaNextPageSelector() = null
override fun popularMangaSelector() = ".ranking-item"
override fun popularMangaFromElement(element: Element): SManga {
return SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
title = element.selectFirst(".title-text")!!.text()
setImageUrlFromElement(element)
}
}
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.selectFirst(".series-h-info")!!
val authorElements = infoElement.select(".series-h-credit-user-item .article-text")
val updateDateElement = infoElement.selectFirst(".series-h-tag-label")
return SManga.create().apply {
title = infoElement.selectFirst("h1 > span:not(.g-hidden)")!!.text()
author = authorElements.joinToString { it.text() }
description = infoElement.selectFirst(".series-h-credit-info-text-text p")?.wholeText()?.trim()
setImageUrlFromElement(document.selectFirst(".series-h-img"))
status = if (updateDateElement != null) SManga.ONGOING else SManga.COMPLETED
}
}
override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url + "/list", headers)
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select(chapterListSelector()).mapIndexed { index, element ->
chapterFromElement(element).apply {
chapter_number = index.toFloat()
if (url.isEmpty()) { // need login, set a dummy url and append lock icon for chapter name
val hasLockElement = element.selectFirst(".g-payment-article.wait-free-enabled")
url = response.request.url.newBuilder().fragment("$index-$DUMMY_URL_SUFFIX").build().toString()
name = (if (hasLockElement != null) LOCK_ICON else PAY_ICON) + name
}
}
}
}
override fun chapterListSelector() = ".article-ep-list-item-img-link"
override fun chapterFromElement(element: Element): SChapter {
return SChapter.create().apply {
setUrlWithoutDomain(element.absUrl("data-href"))
name = element.selectFirst(".series-ep-list-item-h-text")!!.text()
setUploadDate(element.selectFirst(".series-ep-list-date-time"))
scanlator = PUBLISHER
}
}
override fun pageListRequest(chapter: SChapter): Request {
if (chapter.url.endsWith(DUMMY_URL_SUFFIX)) {
throw Exception("Login required to see this chapter")
}
return super.pageListRequest(chapter)
}
override fun pageListParse(document: Document): List<Page> {
val pageList = mutableListOf<Page>()
// Get some essential info from document
val viewer = document.selectFirst("#comici-viewer")!!
val comiciViewerId = viewer.attr("comici-viewer-id")
val memberJwt = viewer.attr("data-member-jwt")
val requestUrl = "$baseUrl/book/contentsInfo".toHttpUrl().newBuilder()
.addQueryParameter("comici-viewer-id", comiciViewerId)
.addQueryParameter("user-id", memberJwt)
.addQueryParameter("page-from", "0")
// Initial request to get total pages
val initialRequest = GET(requestUrl.addQueryParameter("page-to", "1").build(), headers)
client.newCall(initialRequest).execute().use { initialResponseRaw ->
if (!initialResponseRaw.isSuccessful) {
throw Exception("Failed to get page list")
}
// Get all pages
val pageTo = initialResponseRaw.parseAs<PageResponse>().totalPages.toString()
val getAllPagesUrl = requestUrl.setQueryParameter("page-to", pageTo).build()
val getAllPagesRequest = GET(getAllPagesUrl, headers)
client.newCall(getAllPagesRequest).execute().use {
if (!it.isSuccessful) {
throw Exception("Failed to get page list")
}
it.parseAs<PageResponse>().result.forEach { resultItem ->
// Origin scramble string is something like [6, 9, 14, 15, 8, 3, 4, 12, 1, 5, 0, 7, 13, 2, 11, 10]
val scramble = resultItem.scramble.drop(1).dropLast(1).replace(", ", "-")
// Add fragment to let interceptor descramble the image
val imageUrl = resultItem.imageUrl.toHttpUrl().newBuilder().fragment(scramble).build()
pageList.add(
Page(index = resultItem.sort, imageUrl = imageUrl.toString()),
)
}
}
}
return pageList
}
override fun imageUrlParse(document: Document): String {
throw UnsupportedOperationException()
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val searchUrl = "$baseUrl/search".toHttpUrl().newBuilder()
.setQueryParameter("keyword", query)
.setQueryParameter("page", page.toString())
.build()
return GET(searchUrl, headers)
}
override fun searchMangaNextPageSelector() = null
override fun searchMangaSelector() = ".series-list a"
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.absUrl("href"))
title = element.selectFirst(".manga-title")!!.text()
setImageUrlFromElement(element)
}
override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
override fun latestUpdatesNextPageSelector() = null
override fun latestUpdatesSelector() = "h2:contains(新連載) + .feature-list > .feature-item"
override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
title = element.selectFirst("h3")!!.text()
setImageUrlFromElement(element)
}
// ========================================= Helper Functions =====================================
companion object {
private const val PUBLISHER = "BUSHIROAD WORKS"
private val imageUrlRegex by lazy { Regex("^.*?webp") }
private val DATE_PARSER by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) }
private const val DUMMY_URL_SUFFIX = "NeedLogin"
private const val PAY_ICON = "💴 "
private const val LOCK_ICON = "🔒 "
}
/**
* Set cover image url from [element] for [SManga]
*/
private fun SManga.setImageUrlFromElement(element: Element?) {
if (element == null) {
return
}
val match = imageUrlRegex.find(element.selectFirst("source")!!.attr("data-srcset"))
// Add missing protocol
if (match != null) {
this.thumbnail_url = "https:${match.value}"
}
}
/**
* Set date_upload to [SChapter], parsing from string like "3月31日" to UNIX Epoch time.
*/
private fun SChapter.setUploadDate(element: Element?) {
if (element == null) {
return
}
this.date_upload = DATE_PARSER.tryParse(element.attr("datetime"))
}
}
class ComicGrowl : ComiciViewer(
"Comic Growl",
"https://comic-growl.com",
"all",
)

View File

@ -1,16 +0,0 @@
package eu.kanade.tachiyomi.extension.all.comicgrowl
import kotlinx.serialization.Serializable
@Serializable
class PageResponse(
val totalPages: Int,
val result: List<PageResponseResult>,
)
@Serializable
class PageResponseResult(
val imageUrl: String,
val scramble: String,
val sort: Int,
)

View File

@ -0,0 +1,12 @@
ext {
extName = 'Comick (Unoriginal)'
extClass = '.ComickFactory'
extVersionCode = 3
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,441 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import android.util.Log
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.firstInstance
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import okhttp3.CacheControl
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.closeQuietly
import okio.IOException
import org.jsoup.Jsoup
import java.text.SimpleDateFormat
import java.util.Locale
class Comick(
override val lang: String,
private val siteLang: String = lang,
) : HttpSource(), ConfigurableSource {
override val name = "Comick (Unoriginal)"
override val supportsLatest = true
private val preferences = getPreferences()
override val baseUrl: String
get() {
val index = preferences.getString(DOMAIN_PREF, "0")!!.toInt()
.coerceAtMost(domains.size - 1)
return domains[index]
}
override val client = network.cloudflareClient.newBuilder()
// Referer in interceptor due to domain change preference
.addNetworkInterceptor { chain ->
val request = chain.request().newBuilder()
.header("Referer", "$baseUrl/")
.build()
chain.proceed(request)
}
// fix disk cache
.apply {
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
}
.build()
override fun popularMangaRequest(page: Int): Request {
val url = "$baseUrl/api/comics/top".toHttpUrl().newBuilder().apply {
val days = when (page) {
1, 4 -> 7
2, 5 -> 30
3, 6 -> 90
else -> throw UnsupportedOperationException()
}
val type = when (page) {
1, 2, 3 -> "follow"
4, 5, 6 -> "most_follow_new"
else -> throw UnsupportedOperationException()
}
addQueryParameter("days", days.toString())
addQueryParameter("type", type)
fragment(page.toString())
}.build()
return GET(url, headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Data<List<BrowseComic>>>()
val page = response.request.url.fragment!!.toInt()
return MangasPage(
mangas = data.data.map(BrowseComic::toSManga),
hasNextPage = page < 6,
)
}
override fun latestUpdatesRequest(page: Int) =
GET("$baseUrl/api/chapters/latest?order=new&page=$page", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val data = response.parseAs<Data<List<BrowseComic>>>()
return MangasPage(
mangas = data.data.map(BrowseComic::toSManga),
hasNextPage = data.data.size == 100,
)
}
private var nextCursor: String? = null
private val spaceSlashRegex = Regex("[ /]")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (page == 1) {
nextCursor = null
}
val url = "$baseUrl/api/search".toHttpUrl().newBuilder().apply {
filters.firstInstance<SortFilter>().let {
addQueryParameter("order_by", it.selected)
addQueryParameter("order_direction", if (it.state!!.ascending) "asc" else "desc")
}
filters.firstInstanceOrNull<GenreFilter>()?.let { genre ->
genre.included.forEach {
addQueryParameter("genres", it)
}
genre.excluded.forEach {
addQueryParameter("excludes", it)
}
}
filters.firstInstanceOrNull<TagFilterText>()?.let { text ->
text.state.split(",").filter(String::isNotBlank).forEach {
val value = it.trim().lowercase().replace(spaceSlashRegex, "-")
addQueryParameter(
if (value.startsWith("-")) "excluded_tags" else "tags",
value.replaceFirst("-", ""),
)
}
}
filters.firstInstanceOrNull<TagFilter>()?.let { tag ->
tag.included.forEach {
addQueryParameter("tags", it)
}
tag.excluded.forEach {
addQueryParameter("excluded_tags", it)
}
}
filters.firstInstance<DemographicFilter>().checked.forEach {
addQueryParameter("demographic", it)
}
filters.firstInstance<CreatedAtFilter>().selected?.let {
addQueryParameter("time", it)
}
filters.firstInstance<TypeFilter>().checked.forEach {
addQueryParameter("country", it)
}
filters.firstInstance<MinimumChaptersFilter>().state.let {
if (it.isNotBlank()) {
if (it.toIntOrNull() == null) {
throw Exception("Invalid minimum chapters value: $it")
}
addQueryParameter("minimum", it)
}
}
filters.firstInstance<StatusFilter>().selected?.let {
addQueryParameter("status", it)
}
filters.firstInstance<ReleaseFrom>().selected?.let {
addQueryParameter("from", it)
}
filters.firstInstance<ReleaseTo>().selected?.let {
addQueryParameter("to", it)
}
filters.firstInstance<ContentRatingFilter>().selected?.let {
addQueryParameter("content_rating", it)
}
addQueryParameter("showAll", "false")
addQueryParameter("exclude_mylist", "false")
if (query.isNotBlank()) {
if (query.trim().length < 3) {
throw Exception("Query must be at least 3 characters")
}
addQueryParameter("q", query.trim())
}
addQueryParameter("type", "comic")
if (page > 1) {
addQueryParameter("cursor", nextCursor)
}
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val data = response.parseAs<SearchResponse>()
nextCursor = data.cursor
return MangasPage(
mangas = data.data.map(BrowseComic::toSManga),
hasNextPage = data.cursor != null,
)
}
private val metadataClient = client.newBuilder()
.addNetworkInterceptor { chain ->
chain.proceed(chain.request()).newBuilder()
.header("Cache-Control", "max-age=${24 * 60 * 60}")
.removeHeader("Pragma")
.removeHeader("Expires")
.build()
}.build()
override fun getFilterList(): FilterList = runBlocking(Dispatchers.IO) {
val filters: MutableList<Filter<*>> = mutableListOf(
SortFilter(),
DemographicFilter(),
TypeFilter(),
CreatedAtFilter(),
MinimumChaptersFilter(),
StatusFilter(),
ContentRatingFilter(),
ReleaseFrom(),
ReleaseTo(),
)
val response = metadataClient.newCall(
GET("$baseUrl/api/metadata", headers, CacheControl.FORCE_CACHE),
).await()
val getTags = preferences.getBoolean(GET_TAGS, true)
val textTags: List<Filter<*>> = listOf(
Filter.Separator(),
Filter.Header("Separate tags with commas (,)"),
Filter.Header("Prepend with dash (-) to exclude"),
TagFilterText(),
Filter.Separator(),
)
if (!response.isSuccessful) {
metadataClient.newCall(
GET("$baseUrl/api/metadata", headers, CacheControl.FORCE_NETWORK),
).enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
response.closeQuietly()
}
override fun onFailure(call: Call, e: IOException) {
Log.e(name, "Unable to fetch filters", e)
}
},
)
if (!getTags) {
filters.addAll(
index = 2,
textTags,
)
}
filters.addAll(
index = 0,
listOf(
Filter.Header("Press 'reset' to load genres ${if (getTags) "and tags" else ""}"),
Filter.Separator(),
),
)
return@runBlocking FilterList(filters)
}
val data = try {
response.parseAs<Metadata>()
} catch (e: Throwable) {
Log.e(name, "Unable to parse filters", e)
if (!getTags) {
filters.addAll(
index = 2,
textTags,
)
}
filters.addAll(
index = 0,
listOf(
Filter.Header("Failed to parse genres ${if (getTags) "and tags" else ""}"),
Filter.Separator(),
),
)
return@runBlocking FilterList(filters)
}
filters.add(
index = 3,
GenreFilter(data.genres),
)
if (!getTags) {
filters.addAll(
index = 4,
textTags,
)
} else {
filters.add(
index = 4,
TagFilter(data.tags),
)
}
return@runBlocking FilterList(filters)
}
override fun mangaDetailsRequest(manga: SManga) =
GET("$baseUrl/comic/${manga.url}", headers)
override fun mangaDetailsParse(response: Response): SManga {
val data = response.asJsoup()
.selectFirst("#comic-data")!!.data()
.parseAs<ComicData>()
return SManga.create().apply {
title = data.title
url = data.slug
thumbnail_url = data.thumbnail
status = when (data.status) {
1 -> SManga.ONGOING
2 -> if (data.translationCompleted) SManga.COMPLETED else SManga.PUBLISHING_FINISHED
3 -> SManga.CANCELLED
4 -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
author = data.authors.joinToString { it.name }
artist = data.artists.joinToString { it.name }
description = buildString {
append(
Jsoup.parseBodyFragment(data.desc).wholeText(),
)
if (data.titles.isNotEmpty()) {
append("\n\n Alternative Titles: \n")
data.titles.forEach {
append("- ", it.title.trim(), "\n")
}
}
}.trim()
genre = buildList {
when (data.country) {
"jp" -> add("Manga")
"cn" -> add("Manhua")
"ko" -> add("Manhwa")
}
when (data.contentRating) {
"suggestive" -> add("Content Rating: Suggestive")
"erotica" -> add("Content Rating: Erotica")
}
addAll(data.genres.map { it.genres.name })
}.joinToString()
}
}
override fun chapterListRequest(manga: SManga) =
GET("$baseUrl/api/comics/${manga.url}/chapter-list?lang=$siteLang", headers)
override fun chapterListParse(response: Response): List<SChapter> {
var data = response.parseAs<ChapterList>()
var page = 2
val chapters = data.data.toMutableList()
while (data.hasNextPage()) {
val url = response.request.url.newBuilder()
.addQueryParameter("page", page.toString())
.build()
data = client.newCall(GET(url, headers)).execute()
.parseAs()
chapters += data.data
page++
}
val mangaSlug = response.request.url.pathSegments[2]
return chapters.map {
SChapter.create().apply {
url = "/comic/$mangaSlug/${it.hid}-chapter-${it.chap}-${it.lang}"
name = buildString {
if (!it.vol.isNullOrBlank()) {
append("Vol. ", it.vol, " ")
}
append("Ch. ", it.chap)
if (!it.title.isNullOrBlank()) {
append(": ", it.title)
}
}
date_upload = dateFormat.tryParse(it.createdAt)
scanlator = it.groups.joinToString()
}
}
}
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
override fun pageListParse(response: Response): List<Page> {
val data = response.asJsoup()
.selectFirst("#sv-data")!!.data()
.parseAs<PageListData>()
return data.chapter.images.mapIndexed { index, image ->
Page(index, imageUrl = image.url)
}
}
override fun imageUrlParse(response: Response): String {
throw UnsupportedOperationException()
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = DOMAIN_PREF
title = "Preferred Domain"
entries = domains
entryValues = Array(domains.size) { it.toString() }
summary = "%s"
setDefaultValue("0")
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = GET_TAGS
title = "Tags Input Type"
summaryOn = "Tags will be in a form of scrollable list"
summaryOff = "Tags will need to be inputted manually"
setDefaultValue(true)
}.also(screen::addPreference)
}
}
private val domains = arrayOf("https://comick.live", "https://comick.art")
private const val DOMAIN_PREF = "domain_pref"
private const val GET_TAGS = "get_tags"

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import eu.kanade.tachiyomi.source.SourceFactory
class ComickFactory : SourceFactory {
// as of 2025-10-15, the commented languages have 0 chapters uploaded
// from: /api/languages
override fun createSources() = listOf(
Comick("en"),
// Comick("pt-br", "pt-BR"),
// Comick("es-419", "es-la"),
Comick("ru"),
Comick("vi"),
Comick("fr"),
Comick("pl"),
Comick("id"),
Comick("tr"),
Comick("it"),
Comick("es"),
Comick("uk"),
// Comick("ar"),
// Comick("zh-hk", "zh-Hant"),
// Comick("hu"),
// Comick("zh", "zh-Hans"),
Comick("de"),
Comick("ko"),
Comick("th"),
// Comick("ca"),
// Comick("bg"),
// Comick("fa"),
Comick("ro"),
// Comick("cs"),
// Comick("mn"),
// Comick("pt"),
// Comick("he"),
// Comick("hi"),
// Comick("tl"),
Comick("ms"),
// Comick("fi"),
// Comick("eu"),
// Comick("kk"),
// Comick("sr"),
// Comick("my"),
Comick("ja"),
// Comick("el"),
// Comick("nl"),
// Comick("bn"),
// Comick("uz"),
// Comick("eo"),
// Comick("ka"),
// Comick("lt"),
// Comick("da"),
// Comick("ta"),
Comick("sv"),
// Comick("be"),
// Comick("gl"),
// Comick("cv"),
// Comick("hr"),
// Comick("la"),
// Comick("ur"),
// Comick("ne"),
Comick("no"),
// Comick("sq"),
// Comick("ga"),
// Comick("jv"),
// Comick("te"),
// Comick("sl"),
// Comick("et"),
// Comick("az"),
// Comick("sk"),
// Comick("af"),
// Comick("lv"),
)
}

View File

@ -0,0 +1,139 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonTransformingSerializer
@Serializable
class Data<T>(
val data: T,
)
@Serializable
class SearchResponse(
val data: List<BrowseComic>,
@SerialName("next_cursor")
val cursor: String? = null,
)
@Serializable
class BrowseComic(
@SerialName("default_thumbnail")
private val thumbnail: String,
private val slug: String,
private val title: String,
) {
fun toSManga() = SManga.create().apply {
url = slug
title = this@BrowseComic.title
thumbnail_url = thumbnail
}
}
@Serializable
class Metadata(
val genres: List<Name>,
val tags: List<Name>,
) {
@Serializable
class Name(
val name: String,
val slug: String,
)
}
@Serializable
class ComicData(
val title: String,
val slug: String,
@SerialName("default_thumbnail")
val thumbnail: String,
val status: Int,
@SerialName("translation_completed")
val translationCompleted: Boolean,
val artists: List<Name>,
val authors: List<Name>,
val desc: String,
@SerialName("content_rating")
val contentRating: String,
val country: String,
@SerialName("md_comic_md_genres")
val genres: List<Genres>,
@SerialName("md_titles")
@Serializable(with = TitleTransform::class)
val titles: List<Title>,
) {
@Serializable
class Name(
val name: String,
)
@Serializable
class Title(
val title: String,
)
@Serializable
class Genres(
@SerialName("md_genres")
val genres: Name,
)
}
object TitleTransform : JsonTransformingSerializer<List<ComicData.Title>>(
ListSerializer(ComicData.Title.serializer()),
) {
override fun transformDeserialize(element: JsonElement): JsonElement {
if (element !is JsonObject) return element
return JsonArray(element.values.toList())
}
}
@Serializable
class ChapterList(
val data: List<Chapter>,
private val pagination: Pagination,
) {
fun hasNextPage() = pagination.page < pagination.lastPage
@Serializable
class Chapter(
val hid: String,
val chap: String,
val vol: String?,
val lang: String,
val title: String?,
@SerialName("created_at")
val createdAt: String,
@SerialName("group_name")
val groups: List<String>,
)
@Serializable
class Pagination(
@SerialName("current_page")
val page: Int,
@SerialName("last_page")
val lastPage: Int,
)
}
@Serializable
class PageListData(
val chapter: ChapterData,
) {
@Serializable
class ChapterData(
val images: List<Image>,
) {
@Serializable
class Image(
val url: String,
)
}
}

View File

@ -0,0 +1,151 @@
package eu.kanade.tachiyomi.extension.all.comicklive
import eu.kanade.tachiyomi.source.model.Filter
import java.util.Calendar
import kotlin.collections.filter
abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String>>,
) : Filter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second.takeIf { it.isNotEmpty() }
}
class CheckBoxFilter(name: String, val value: String) : Filter.CheckBox(name)
abstract class CheckBoxGroup(
name: String,
options: List<Pair<String, String>>,
) : Filter.Group<CheckBoxFilter>(
name,
options.map { CheckBoxFilter(it.first, it.second) },
) {
val checked get() = state.filter { it.state }.map { it.value }
}
class TriStateFilter(name: String, val slug: String) : Filter.TriState(name)
abstract class TriStateGroupFilter(
name: String,
options: List<Pair<String, String>>,
) : Filter.Group<TriStateFilter>(
name,
options.map { TriStateFilter(it.first, it.second) },
) {
val included get() = state.filter { it.isIncluded() }.map { it.slug }
val excluded get() = state.filter { it.isExcluded() }.map { it.slug }
}
private val getSortsList = listOf(
"Latest" to "created_at",
"Popular" to "user_follow_count",
"Highest Rating" to "rating",
"Last Uploaded" to "uploaded",
)
class SortFilter : Filter.Sort(
name = "Sort",
values = getSortsList.map { it.first }.toTypedArray(),
state = Selection(0, false),
) {
val selected get() = state?.let { getSortsList[it.index] }?.second.takeIf { it?.isNotEmpty() ?: false }
}
class GenreFilter(genres: List<Metadata.Name>) : TriStateGroupFilter(
name = "Genre",
options = genres.map { it.name to it.slug },
)
class TagFilter(tags: List<Metadata.Name>) : TriStateGroupFilter(
name = "Tags",
options = tags.map { it.name to it.slug },
)
class TagFilterText : Filter.Text(
name = "Tags",
)
class DemographicFilter : CheckBoxGroup(
name = "Demographic",
options = listOf(
"Shounen" to "1",
"Josei" to "2",
"Seinen" to "3",
"Shoujo" to "4",
"None" to "0",
),
)
class CreatedAtFilter : SelectFilter(
name = "Created At",
options = listOf(
"" to "",
"3 days ago" to "3",
"7 days ago" to "7",
"30 days ago" to "30",
"3 months ago" to "90",
"6 months ago" to "180",
"1 year ago" to "365",
"2 years ago" to "730",
),
)
class TypeFilter : CheckBoxGroup(
name = "Type",
options = listOf(
"Manga" to "jp",
"Manhwa" to "kr",
"Manhua" to "cn",
"Others" to "others",
),
)
class MinimumChaptersFilter : Filter.Text(
name = "Minimum Chapters",
)
class StatusFilter : SelectFilter(
name = "Status",
options = listOf(
"" to "",
"Ongoing" to "1",
"Completed" to "2",
"Cancelled" to "3",
"Hiatus" to "4",
),
)
class ContentRatingFilter : SelectFilter(
name = "Content Rating",
options = listOf(
"" to "",
"Safe" to "safe",
"Suggestive" to "suggestive",
"Erotica" to "erotica",
),
)
class ReleaseFrom : SelectFilter(
name = "Release From",
options = buildList {
add(("" to ""))
Calendar.getInstance().get(Calendar.YEAR).downTo(1990).mapTo(this) {
("$it" to it.toString())
}
add(("Before 1990" to "0"))
},
)
class ReleaseTo : SelectFilter(
name = "Release To",
options = buildList {
add(("" to ""))
Calendar.getInstance().get(Calendar.YEAR).downTo(1990).mapTo(this) {
("$it" to it.toString())
}
add(("Before 1990" to "0"))
},
)

View File

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.extension.all.comico
import eu.kanade.tachiyomi.source.SourceFactory
class ComicoFactory : SourceFactory {
open class PocketComics(langCode: String) :
Comico("https://www.pocketcomics.com", "POCKET COMICS", langCode)
class ComicoJP : Comico("https://www.comico.jp", "コミコ", "ja-JP")
class ComicoKR : Comico("https://www.comico.kr", "코미코", "ko-KR")
override fun createSources() = listOf(
PocketComics("en-US"),
PocketComics("zh-TW"),
ComicoJP(),
ComicoKR(),
)
}

View File

@ -32,19 +32,6 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="*.guya.moe" />
<data android:host="guya.moe" />
<data
android:pathPattern="/proxy/..*"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="*.imgur.com" />
<data android:host="imgur.com" />

View File

@ -20,6 +20,6 @@ If you've setup the Remote Storage via WebView the Recent tab shows your recent,
You can visit the [Cubari](https://cubari.moe/) website for for more information.
### How do I add a gallery to Cubari?
You can directly open a imgur or Cubari link in the extension.
You can directly open a imgur or Cubari link in the extension or paste the url in cubari browse
[Uncomment this if needed]: <> (## Guides)

View File

@ -1,7 +1,7 @@
ext {
extName = 'Cubari'
extClass = '.CubariFactory'
extVersionCode = 25
extVersionCode = 26
}
apply from: "$rootDir/common.gradle"

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.extension.all.cubari
import android.app.Application
import android.os.Build
import android.util.Base64
import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable
@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.Json
import keiyoushi.utils.parseAs
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
@ -20,23 +20,18 @@ import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
open class Cubari(override val lang: String) : HttpSource() {
class Cubari(override val lang: String) : HttpSource() {
final override val name = "Cubari"
override val name = "Cubari"
final override val baseUrl = "https://cubari.moe"
override val baseUrl = "https://cubari.moe"
final override val supportsLatest = true
private val json: Json by injectLazy()
override val supportsLatest = true
override val client = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
@ -48,18 +43,17 @@ open class Cubari(override val lang: String) : HttpSource() {
}
.build()
override fun headersBuilder() = Headers.Builder().apply {
add(
private val cubariHeaders = super.headersBuilder()
.set(
"User-Agent",
"(Android ${Build.VERSION.RELEASE}; " +
"${Build.MANUFACTURER} ${Build.MODEL}) " +
"Tachiyomi/${AppInfo.getVersionName()} " +
Build.ID,
)
}
"Tachiyomi/${AppInfo.getVersionName()} ${Build.ID} " +
"Keiyoushi",
).build()
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/", headers)
return GET("$baseUrl/", cubariHeaders)
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
@ -72,12 +66,12 @@ open class Cubari(override val lang: String) : HttpSource() {
}
override fun latestUpdatesParse(response: Response): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonArray
val result = response.parseAs<JsonArray>()
return parseMangaList(result, SortType.UNPINNED)
}
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/", headers)
return GET("$baseUrl/", cubariHeaders)
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
@ -90,19 +84,22 @@ open class Cubari(override val lang: String) : HttpSource() {
}
override fun popularMangaParse(response: Response): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonArray
val result = response.parseAs<JsonArray>()
return parseMangaList(result, SortType.PINNED)
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(chapterListRequest(manga))
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response -> mangaDetailsParse(response, manga) }
}
// Called when the series is loaded, or when opening in browser
override fun getMangaUrl(manga: SManga): String {
return "$baseUrl${manga.url}"
}
override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$baseUrl${manga.url}", headers)
return chapterListRequest(manga)
}
override fun mangaDetailsParse(response: Response): SManga {
@ -110,7 +107,7 @@ open class Cubari(override val lang: String) : HttpSource() {
}
private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
val result = json.parseToJsonElement(response.body.string()).jsonObject
val result = response.parseAs<JsonObject>()
return parseManga(result, manga)
}
@ -126,17 +123,16 @@ open class Cubari(override val lang: String) : HttpSource() {
val source = urlComponents[2]
val slug = urlComponents[3]
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
return GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
throw Exception("Unused")
throw UnsupportedOperationException()
}
// Called after the request
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
val res = response.body.string()
return parseChapterList(res, manga)
return parseChapterList(response, manga)
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
@ -161,21 +157,20 @@ open class Cubari(override val lang: String) : HttpSource() {
override fun pageListRequest(chapter: SChapter): Request {
return when {
chapter.url.contains("/chapter/") -> {
GET("$baseUrl${chapter.url}", headers)
GET("$baseUrl${chapter.url}", cubariHeaders)
}
else -> {
val url = chapter.url.split("/")
val source = url[2]
val slug = url[3]
GET("$baseUrl/read/api/$source/series/$slug/", headers)
GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
}
}
}
private fun directPageListParse(response: Response): List<Page> {
val res = response.body.string()
val pages = json.parseToJsonElement(res).jsonArray
val pages = response.parseAs<JsonArray>()
return pages.mapIndexed { i, jsonEl ->
val page = if (jsonEl is JsonObject) {
@ -189,7 +184,7 @@ open class Cubari(override val lang: String) : HttpSource() {
}
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
val jsonObj = json.parseToJsonElement(response.body.string()).jsonObject
val jsonObj = response.parseAs<JsonObject>()
val groups = jsonObj["groups"]!!.jsonObject
val groupMap = groups.entries.associateBy({ it.value.jsonPrimitive.content.ifEmpty { "default" } }, { it.key })
val chapterScanlator = chapter.scanlator ?: "default" // workaround for "" as group causing NullPointerException (#13772)
@ -222,23 +217,29 @@ open class Cubari(override val lang: String) : HttpSource() {
}
}
// Stub
override fun pageListParse(response: Response): List<Page> {
throw Exception("Unused")
throw UnsupportedOperationException()
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
query.startsWith(PROXY_PREFIX) -> {
val trimmedQuery = query.removePrefix(PROXY_PREFIX)
// handle direct links or old cubari:source/id format
query.startsWith("https://") || query.startsWith("cubari:") -> {
val (source, slug) = deepLinkHandler(query)
// Only tag for recently read on search
client.newBuilder()
.addInterceptor(RemoteStorageUtils.TagInterceptor())
.build()
.newCall(proxySearchRequest(trimmedQuery))
.newCall(GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders))
.asObservableSuccess()
.map { response ->
proxySearchParse(response, trimmedQuery)
val result = response.parseAs<JsonObject>()
val manga = SManga.create().apply {
url = "/read/$source/$slug"
}
val mangaList = listOf(parseManga(result, manga))
MangasPage(mangaList, false)
}
}
else -> {
@ -259,18 +260,57 @@ open class Cubari(override val lang: String) : HttpSource() {
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return GET("$baseUrl/", headers)
return GET("$baseUrl/", cubariHeaders)
}
private fun proxySearchRequest(query: String): Request {
try {
val queryFragments = query.split("/")
val source = queryFragments[0]
val slug = queryFragments[1]
private fun deepLinkHandler(query: String): Pair<String, String> {
return if (query.startsWith("cubari:")) { // legacy cubari:source/slug format
val queryFragments = query.substringAfter("cubari:").split("/", limit = 2)
queryFragments[0] to queryFragments[1]
} else { // direct url searching
val url = query.toHttpUrl()
val host = url.host
val pathSegments = url.pathSegments
return GET("$baseUrl/read/api/$source/series/$slug/", headers)
} catch (e: Exception) {
throw Exception(SEARCH_FALLBACK_MSG)
if (
host.endsWith("imgur.com") &&
pathSegments.size >= 2 &&
pathSegments[0] in listOf("a", "gallery")
) {
"imgur" to pathSegments[1]
} else if (
host.endsWith("reddit.com") &&
pathSegments.size >= 2 &&
pathSegments[0] == "gallery"
) {
"reddit" to pathSegments[1]
} else if (
host == "imgchest.com" &&
pathSegments.size >= 2 &&
pathSegments[0] == "p"
) {
"imgchest" to pathSegments[1]
} else if (
host.endsWith("catbox.moe") &&
pathSegments.size >= 2 &&
pathSegments[0] == "c"
) {
"catbox" to pathSegments[1]
} else if (
host.endsWith("cubari.moe") &&
pathSegments.size >= 3
) {
pathSegments[1] to pathSegments[2]
} else if (
host.endsWith(".githubusercontent.com")
) {
val src = host.substringBefore(".")
val path = url.encodedPath
"gist" to Base64.encodeToString("$src$path".toByteArray(), Base64.NO_PADDING)
} else {
throw Exception(SEARCH_FALLBACK_MSG)
}
}
}
@ -279,7 +319,7 @@ open class Cubari(override val lang: String) : HttpSource() {
}
private fun searchMangaParse(response: Response, query: String): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonArray
val result = response.parseAs<JsonArray>()
val filterList = result.asSequence()
.map { it as JsonObject }
@ -289,23 +329,14 @@ open class Cubari(override val lang: String) : HttpSource() {
return parseMangaList(JsonArray(filterList), SortType.ALL)
}
private fun proxySearchParse(response: Response, query: String): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonObject
return parseSearchList(result, query)
}
// ------------- Helpers and whatnot ---------------
private val volumeNotSpecifiedTerms = setOf("Uncategorized", "null", "")
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> {
val jsonObj = json.parseToJsonElement(payload).jsonObject
private fun parseChapterList(response: Response, manga: SManga): List<SChapter> {
val jsonObj = response.parseAs<JsonObject>()
val groups = jsonObj["groups"]!!.jsonObject
val chapters = jsonObj["chapters"]!!.jsonObject
val seriesSlug = jsonObj["slug"]!!.jsonPrimitive.content
val seriesPrefs = Injekt.get<Application>().getSharedPreferences("source_${id}_updateTime:$seriesSlug", 0)
val seriesPrefsEditor = seriesPrefs.edit()
val chapterList = chapters.entries.flatMap { chapterEntry ->
val chapterNum = chapterEntry.key
@ -327,13 +358,7 @@ open class Cubari(override val lang: String) : HttpSource() {
date_upload = if (releaseDate != null) {
releaseDate.jsonPrimitive.double.toLong() * 1000
} else {
val currentTimeMillis = System.currentTimeMillis()
if (!seriesPrefs.contains(chapterNum)) {
seriesPrefsEditor.putLong(chapterNum, currentTimeMillis)
}
seriesPrefs.getLong(chapterNum, currentTimeMillis)
0L
}
name = buildString {
@ -351,8 +376,6 @@ open class Cubari(override val lang: String) : HttpSource() {
}
}
seriesPrefsEditor.apply()
return chapterList.sortedByDescending { it.chapter_number }
}
@ -375,16 +398,6 @@ open class Cubari(override val lang: String) : HttpSource() {
return MangasPage(mangaList, false)
}
private fun parseSearchList(payload: JsonObject, query: String): MangasPage {
val tempManga = SManga.create().apply {
url = "/read/$query"
}
val mangaList = listOf(parseManga(payload, tempManga))
return MangasPage(mangaList, false)
}
private fun parseManga(jsonObj: JsonObject, mangaReference: SManga? = null): SManga =
SManga.create().apply {
title = jsonObj["title"]!!.jsonPrimitive.content
@ -413,11 +426,10 @@ open class Cubari(override val lang: String) : HttpSource() {
}
companion object {
const val PROXY_PREFIX = "cubari:"
const val AUTHOR_FALLBACK = "Unknown"
const val ARTIST_FALLBACK = "Unknown"
const val DESCRIPTION_FALLBACK = "No description."
const val SEARCH_FALLBACK_MSG = "Unable to parse. Is your query in the format of $PROXY_PREFIX<source>/<slug>?"
const val SEARCH_FALLBACK_MSG = "Please enter a valid Cubari URL"
enum class SortType {
PINNED,

View File

@ -11,59 +11,20 @@ class CubariUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val host = intent?.data?.host
val pathSegments = intent?.data?.pathSegments
if (host != null && pathSegments != null) {
val query = with(host) {
when {
equals("m.imgur.com") || equals("imgur.com") -> fromSource("imgur", pathSegments)
equals("m.reddit.com") || equals("reddit.com") || equals("www.reddit.com") -> fromSource("reddit", pathSegments)
equals("imgchest.com") -> fromSource("imgchest", pathSegments)
equals("catbox.moe") || equals("www.catbox.moe") -> fromSource("catbox", pathSegments)
else -> fromCubari(pathSegments)
}
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", intent.data.toString())
putExtra("filter", packageName)
}
if (query == null) {
Log.e("CubariUrlActivity", "Unable to parse URI from intent $intent")
finish()
exitProcess(1)
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", query)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("CubariUrlActivity", e.toString())
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("CubariUrlActivity", "Unable to find activity", e)
}
finish()
exitProcess(0)
}
private fun fromSource(source: String, pathSegments: List<String>): String? {
if (pathSegments.size >= 2) {
val id = pathSegments[1]
return "${Cubari.PROXY_PREFIX}$source/$id"
}
return null
}
private fun fromCubari(pathSegments: MutableList<String>): String? {
return if (pathSegments.size >= 3) {
val source = pathSegments[1]
val slug = pathSegments[2]
"${Cubari.PROXY_PREFIX}$source/$slug"
} else {
null
}
}
}

View File

@ -1,7 +1,7 @@
ext {
extName = 'E-Hentai'
extClass = '.EHFactory'
extVersionCode = 25
extVersionCode = 26
isNsfw = true
}

View File

@ -351,7 +351,29 @@ abstract class EHentai(
override fun pageListParse(response: Response) = throw UnsupportedOperationException()
override fun imageUrlParse(response: Response): String = response.asJsoup().select("#img").attr("abs:src")
override fun imageUrlParse(response: Response): String {
return imageUrlParse(response, true)
}
private fun imageUrlParse(response: Response, isGetBakImageUrl: Boolean): String {
val doc = response.asJsoup()
val imgUrl = doc.select("#img").attr("abs:src")
if (!isGetBakImageUrl) {
return imgUrl
}
// from https://github.com/Miuzarte/EHentai-go/blob/dd9a24adb13300c028c35f53b9eff31b51966def/query.go#L695
val loadfail = doc.selectFirst("#loadfail") ?: return imgUrl
val onclick = loadfail.attr("onclick")
val nlValue = Regex("nl\\('(.+?)'\\)").find(onclick)?.groupValues?.get(1)
if (nlValue.isNullOrEmpty()) return imgUrl
val bakUrl = response.request.url.newBuilder()
.addQueryParameter("nl", nlValue)
.toString()
return "$imgUrl#$bakUrl"
}
private val cookiesHeader by lazy {
val cookies = mutableMapOf<String, String>()
@ -398,6 +420,25 @@ abstract class EHentai(
override val client = network.cloudflareClient.newBuilder()
.cookieJar(CookieJar.NO_COOKIES)
.addInterceptor { chain ->
val request = chain.request()
val result = runCatching { chain.proceed(request) }
val bakUrl = request.url.fragment
?: return@addInterceptor result.getOrThrow()
if (result.isFailure || result.getOrNull()?.isSuccessful != true) {
result.getOrNull()?.close()
val newRequest = GET(bakUrl, headers)
val newImageUrl = imageUrlParse(chain.proceed(newRequest), false)
val newImageRequest = request.newBuilder()
.url(newImageUrl)
.build()
chain.proceed(newImageRequest)
} else {
result.getOrThrow()
}
}
.addInterceptor { chain ->
val newReq = chain
.request()

View File

@ -0,0 +1,8 @@
ext {
extName = 'HDoujin'
extClass = '.HDoujinFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,195 @@
package eu.kanade.tachiyomi.extension.all.hdoujin
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
private val dateFormat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
@Serializable
class MangaDetail(
val id: Int,
val key: String,
val title: String,
val title_short: String?,
val created_at: Long = 0L,
val updated_at: Long?,
val subtitle: String?,
val subtitle_short: String?,
val thumbnails: Thumbnails,
val tags: List<Tag> = emptyList(),
) {
@Serializable
class Tag(
val name: String,
val namespace: Int = 0,
)
@Serializable
class Thumbnail(
val path: String,
)
@Serializable
class Thumbnails(
val base: String,
val main: Thumbnail,
val entries: List<Thumbnail>,
)
fun toSManga() = SManga.create().apply {
val artists = mutableListOf<String>()
val circles = mutableListOf<String>()
val parodies = mutableListOf<String>()
val characters = mutableListOf<String>()
val females = mutableListOf<String>()
val males = mutableListOf<String>()
val mixed = mutableListOf<String>()
val language = mutableListOf<String>()
val other = mutableListOf<String>()
val uploaders = mutableListOf<String>()
val tags = mutableListOf<String>()
this@MangaDetail.tags.forEach { tag ->
when (tag.namespace) {
1 -> artists.add(tag.name)
2 -> circles.add(tag.name)
3 -> parodies.add(tag.name)
5 -> characters.add(tag.name)
7 -> tag.name.takeIf { it != "anonymous" }?.let { uploaders.add(it) }
8 -> males.add(tag.name + "")
9 -> females.add(tag.name + "")
10 -> mixed.add(tag.name)
11 -> language.add(tag.name)
12 -> other.add(tag.name)
else -> tags.add(tag.name)
}
}
var appended = false
fun List<String>.joinAndCapitalizeEach(): String? = this.emptyToNull()?.joinToString { it.capitalizeEach() }?.apply { appended = true }
thumbnail_url = thumbnails.base + thumbnails.main.path
author = (circles.emptyToNull() ?: artists).joinToString { it.capitalizeEach() }
artist = artists.joinToString { it.capitalizeEach() }
genre = (artists + circles + parodies + characters + tags + females + males + mixed + other).joinToString { it.capitalizeEach() }
description = buildString {
circles.joinAndCapitalizeEach()?.let {
append("Circles: ", it, "\n")
}
uploaders.joinAndCapitalizeEach()?.let {
append("Uploaders: ", it, "\n")
}
parodies.joinAndCapitalizeEach()?.let {
append("Parodies: ", it, "\n")
}
characters.joinAndCapitalizeEach()?.let {
append("Characters: ", it, "\n")
}
if (appended) append("\n")
try {
append("Posted: ", dateFormat.format(created_at), "\n")
} catch (_: Exception) {}
append("Pages: ", thumbnails.entries.size, "\n\n")
if (!subtitle.isNullOrBlank() || !subtitle_short.isNullOrBlank()) {
append("Alternative Title(s): ", mutableSetOf(subtitle, subtitle_short).filter { !it.isNullOrBlank() }.joinToString { "\n- $it" }, "\n\n")
}
}
status = SManga.COMPLETED
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
initialized = true
}
private fun String.capitalizeEach() = this.split(" ").joinToString(" ") { s ->
s.replaceFirstChar { sr ->
if (sr.isLowerCase()) sr.titlecase(Locale.getDefault()) else sr.toString()
}
}
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
return this.ifEmpty { null }
}
}
@Serializable
class Data(
val `0`: DataKey,
val `780`: DataKey? = null,
val `980`: DataKey? = null,
val `1280`: DataKey? = null,
val `1600`: DataKey? = null,
)
@Serializable
class DataKey(
val id: Int? = null,
val size: Double = 0.0,
val key: String? = null,
) {
fun readableSize() = when {
size >= 300 * 1000 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0 * 1000.0))} GB"
size >= 100 * 1000 -> "${"%.2f".format(size / (1000.0 * 1000.0))} MB"
size >= 1000 -> "${"%.2f".format(size / (1000.0))} kB"
else -> "$size B"
}
}
@Serializable
class MangaData(
val data: Data,
) {
fun size(quality: String): String {
val dataKey = when (quality) {
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
"980" -> data.`980` ?: data.`1280` ?: data.`0`
"780" -> data.`780` ?: data.`980` ?: data.`0`
else -> data.`0`
}
return dataKey.readableSize()
}
}
@Serializable
class Entries(
val entries: List<Entry>,
val limit: Int,
val page: Int,
val total: Int,
) {
@Serializable
class Entry(
val id: Int,
val key: String,
val title: String,
val subtitle: String?,
val thumbnail: Thumbnail,
) {
fun toSManga() = SManga.create().apply {
url = "$id/$key"
title = this@Entry.title
thumbnail_url = thumbnail.path
}
}
@Serializable
class Thumbnail(
val path: String,
)
}
@Serializable
class ImagesInfo(
val base: String,
val entries: List<ImagePath>,
)
@Serializable
class ImagePath(
val path: String,
)

View File

@ -0,0 +1,65 @@
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun getFilters(): FilterList {
return FilterList(
SelectFilter("Sort by", getSortsList),
CategoryFilter("Categories"),
Filter.Separator(),
TagType("Tags Include Type", "i"),
TagType("Tags Exclude Type", "e"),
Filter.Separator(),
Filter.Header("Separate tags with commas (,)"),
Filter.Header("Prepend with dash (-) to exclude"),
TextFilter("Tags", "tag"),
TextFilter("Male Tags", "male"),
TextFilter("Female Tags", "female"),
TextFilter("Mixed Tags", "mixed"),
TextFilter("Other Tags", "other"),
Filter.Separator(),
TextFilter("Artists", "artist"),
TextFilter("Parodies", "parody"),
TextFilter("Characters", "character"),
Filter.Separator(),
TextFilter("Uploader", "reason"),
TextFilter("Circles", "circle"),
TextFilter("Languages", "language"),
Filter.Separator(),
Filter.Header("Filter by pages, for example: (>20)"),
TextFilter("Pages", "pages"),
)
}
class CheckBoxFilter(name: String, val value: Int, state: Boolean) : Filter.CheckBox(name, state)
internal class CategoryFilter(name: String) :
Filter.Group<CheckBoxFilter>(
name,
listOf(
Pair("Manga", 2),
Pair("Doujinshi", 4),
Pair("Illustration", 8),
).map { CheckBoxFilter(it.first, it.second, true) },
)
internal class TagType(title: String, val type: String) : Filter.Select<String>(
title,
arrayOf("AND", "OR"),
)
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
internal open class SelectFilter(name: String, val vals: List<Pair<String, String>>, state: Int = 2) :
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
val selected get() = vals[state].second.takeIf { it.isNotEmpty() }
}
private val getSortsList: List<Pair<String, String>> = listOf(
Pair("Title", "2"),
Pair("Pages", "3"),
Pair("Date", ""),
Pair("Views", "8"),
Pair("Favourites", "9"),
Pair("Popular This Week", "popular"),
)

View File

@ -0,0 +1,397 @@
package eu.kanade.tachiyomi.extension.all.hdoujin
import CategoryFilter
import SelectFilter
import TagType
import TextFilter
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.all.hdoujin.Entries.Entry
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import getFilters
import keiyoushi.utils.getPreferences
import keiyoushi.utils.jsonInstance
import kotlinx.serialization.decodeFromString
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import okio.IOException
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class HDoujin(
override val lang: String,
private val siteLang: String = lang,
) : HttpSource(), ConfigurableSource {
override val name = "HDoujin"
override val supportsLatest = true
private val preferences = getPreferences()
private fun quality() = preferences.getString(PREF_IMAGE_RES, "1280")!!
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
private fun alwaysIncludeTags() = preferences.getString(PREF_INCLUDE_TAGS, "")
private fun alwaysExcludeTags() = preferences.getString(PREF_EXCLUDE_TAGS, "")
private fun getTagsPreference(): String {
val include = alwaysIncludeTags()
?.split(",")
?.map(String::trim)
?.filter(String::isNotBlank)
val exclude = alwaysExcludeTags()
?.split(",")
?.map(String::trim)
?.filter(String::isNotBlank)
?.map { "-$it" }
val tags: List<String> = include?.plus(exclude ?: emptyList()) ?: exclude?.plus(include ?: emptyList()) ?: emptyList()
if (tags.isNotEmpty()) {
val tagGroups: Map<String, Set<String>> = tags
.groupBy {
val tag = it.removePrefix("-")
val parts = tag.split(":", limit = 2)
if (parts.size == 2 && parts[0].isNotBlank()) parts[0] else "tag"
}
.mapValues { (_, values) ->
values.mapTo(mutableSetOf()) {
val tag = it.removePrefix("-").split(":").last().trim()
if (it.startsWith("-")) "-$tag" else tag
}
}
return tagGroups.entries.joinToString(" ") { (key, values) ->
"$key:\"${values.joinToString(",")}\""
}
}
return ""
}
override val baseUrl: String = "https://hdoujin.org"
private val baseApiUrl: String = "https://api.hdoujin.org"
private val bookApiUrl: String = "$baseApiUrl/books"
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
.set("Origin", baseUrl)
private val context: Application by injectLazy()
private val handler by lazy { Handler(Looper.getMainLooper()) }
private var _clearance: String? = null
@SuppressLint("SetJavaScriptEnabled")
fun getClearance(): String? {
_clearance?.also { return it }
val latch = CountDownLatch(1)
handler.post {
val webview = WebView(context)
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
blockNetworkImage = true
}
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view!!.evaluateJavascript("window.localStorage.getItem('clearance')") { clearance ->
webview.stopLoading()
webview.destroy()
_clearance = clearance.takeUnless { it == "null" }?.removeSurrounding("\"")
latch.countDown()
}
}
}
webview.loadDataWithBaseURL("$baseUrl/", " ", "text/html", null, null)
}
latch.await(10, TimeUnit.SECONDS)
return _clearance
}
private val clearanceClient = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
val request = chain.request()
val url = request.url
val clearance = getClearance()
?: throw IOException("Open webview to refresh token")
val newUrl = url.newBuilder()
.setQueryParameter("crt", clearance)
.build()
val newRequest = request.newBuilder()
.url(newUrl)
.build()
val response = chain.proceed(newRequest)
if (response.code !in listOf(400, 403)) {
return@addInterceptor response
}
response.close()
_clearance = null
throw IOException("Open webview to refresh token")
}
.rateLimit(3)
.build()
override fun popularMangaRequest(page: Int): Request = GET(
bookApiUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("sort", "8")
addQueryParameter("page", page.toString())
val tags = getTagsPreference()
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language:\"^$siteLang\""
if (tags.isNotBlank()) terms += tags
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
}.build(),
headers,
)
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Entries>()
with(data) {
return MangasPage(
mangas = entries.map(Entry::toSManga),
hasNextPage = limit * page < total,
)
}
}
override fun latestUpdatesRequest(page: Int) = GET(
bookApiUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("page", page.toString())
val tags = getTagsPreference()
val terms: MutableList<String> = mutableListOf()
if (lang != "all") terms += "language:\"^$siteLang\""
if (tags.isNotBlank()) terms += tags
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
}.build(),
headers,
)
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = bookApiUrl.toHttpUrl().newBuilder().apply {
val terms = mutableListOf(query.trim())
if (lang != "all") terms += "language:\"^$siteLang$\""
filters.forEach { filter ->
when (filter) {
is SelectFilter -> {
val value = filter.selected
if (value == "popular") {
addPathSegment(value)
} else {
addQueryParameter("sort", value)
}
}
is CategoryFilter -> {
val activeFilter = filter.state.filter { it.state }
if (activeFilter.isNotEmpty()) {
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
}
}
is TextFilter -> {
if (filter.state.isNotEmpty()) {
val tags = filter.state.split(",").filter(String::isNotBlank).joinToString(",")
if (tags.isNotBlank()) {
terms += "${filter.type}:${if (filter.type == "pages") tags else "\"$tags\""}"
}
}
}
is TagType -> {
if (filter.state > 0) {
addQueryParameter(
filter.type,
when {
filter.type == "i" && filter.state == 0 -> ""
filter.type == "e" && filter.state == 0 -> "1"
else -> ""
},
)
}
}
else -> {}
}
}
if (query.isNotEmpty()) terms.add("title:\"$query\"")
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
override fun getFilterList(): FilterList = getFilters()
private fun getImagesByMangaData(entry: MangaData, entryId: String, entryKey: String): Pair<ImagesInfo, String> {
val data = entry.data
fun getIPK(
ori: DataKey?,
alt1: DataKey?,
alt2: DataKey?,
alt3: DataKey?,
alt4: DataKey?,
): Pair<Int?, String?> {
return Pair(
ori?.id ?: alt1?.id ?: alt2?.id ?: alt3?.id ?: alt4?.id,
ori?.key ?: alt1?.key ?: alt2?.key ?: alt3?.key ?: alt4?.key,
)
}
val (id, public_key) = when (quality()) {
"1600" -> getIPK(data.`1600`, data.`1280`, data.`0`, data.`980`, data.`780`)
"1280" -> getIPK(data.`1280`, data.`1600`, data.`0`, data.`980`, data.`780`)
"980" -> getIPK(data.`980`, data.`1280`, data.`0`, data.`1600`, data.`780`)
"780" -> getIPK(data.`780`, data.`980`, data.`0`, data.`1280`, data.`1600`)
else -> getIPK(data.`0`, data.`1600`, data.`1280`, data.`980`, data.`780`)
}
if (id == null || public_key == null) {
throw Exception("No Images Found")
}
val realQuality = when (id) {
data.`1600`?.id -> "1600"
data.`1280`?.id -> "1280"
data.`980`?.id -> "980"
data.`780`?.id -> "780"
else -> "0"
}
val imagesResponse = clearanceClient.newCall(GET("$bookApiUrl/data/$entryId/$entryKey/$id/$public_key/$realQuality", headers)).execute()
val images = imagesResponse.parseAs<ImagesInfo>() to realQuality
return images
}
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
override fun mangaDetailsRequest(manga: SManga) =
GET("$bookApiUrl/detail/${manga.url}", headers)
override fun mangaDetailsParse(response: Response): SManga {
val mangaDetail = response.parseAs<MangaDetail>()
with(mangaDetail) {
return toSManga().apply {
setUrlWithoutDomain("${mangaDetail.id}/${mangaDetail.key}")
title = if (remadd()) {
title_short
?: mangaDetail.title.shortenTitle()
} else {
mangaDetail.title
}
}
}
}
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
override fun chapterListRequest(manga: SManga) = GET("$bookApiUrl/detail/${manga.url}", headers)
override fun chapterListParse(response: Response): List<SChapter> {
val manga = response.parseAs<MangaDetail>()
return listOf(
SChapter.create().apply {
name = "Chapter"
url = "${manga.id}/${manga.key}"
date_upload = (manga.updated_at ?: manga.created_at)
},
)
}
override fun pageListRequest(chapter: SChapter): Request =
POST("$bookApiUrl/detail/${chapter.url}", headers)
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return clearanceClient.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
override fun pageListParse(response: Response): List<Page> {
val mangaData = response.parseAs<MangaData>()
val url = response.request.url.toString()
val matches = Regex("""/detail/(\d+)/([a-z\d]+)""").find(url)
if (matches == null || matches.groupValues.size < 3) return emptyList()
val imagesInfo = getImagesByMangaData(mangaData, matches.groupValues[1], matches.groupValues[2])
return imagesInfo.first.entries.mapIndexed { index, image ->
Page(index, imageUrl = "${imagesInfo.first.base}/${image.path}?w=${imagesInfo.second}")
}
}
override fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
private inline fun <reified T> Response.parseAs(): T {
return jsonInstance.decodeFromString(body.string())
}
// Settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_IMAGE_RES
title = "Image Resolution"
entries = arrayOf("780x", "980x", "1280x", "1600x", "Original")
entryValues = arrayOf("780", "980", "1280", "1600", "0")
summary = "%s"
setDefaultValue("1280")
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_REM_ADD
title = "Remove additional information in title"
summary = "Remove anything in brackets from manga titles.\n" +
"Reload manga to apply changes to loaded manga."
setDefaultValue(false)
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_INCLUDE_TAGS
title = "Tags to include from browse/search"
summary = "Separate tags with commas (,).\n" +
"Excluding: ${alwaysIncludeTags()}"
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_EXCLUDE_TAGS
title = "Tags to exclude from browse/search"
summary = "Separate tags with commas (,). Supports tag types (females, male, etc), defaults to 'tag' if not specified.\n" +
"Example: 'ai generated, female:hairy, male:hairy'\n" +
"Excluding: ${alwaysExcludeTags()}"
}.also(screen::addPreference)
}
companion object {
private const val PREF_REM_ADD = "pref_remove_additional"
private const val PREF_IMAGE_RES = "pref_image_quality"
private const val PREF_INCLUDE_TAGS = "pref_include_tags"
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.extension.all.hdoujin
import eu.kanade.tachiyomi.source.SourceFactory
class HDoujinFactory : SourceFactory {
override fun createSources() = listOf(
HDoujin("all"),
HDoujin("en", "english"),
HDoujin("ja", "japanese"),
HDoujin("kr", "korean"),
HDoujin("zh", "chinese"),
)
}

View File

@ -3,7 +3,7 @@ ext {
extClass = '.HentaiFoxFactory'
themePkg = 'galleryadults'
baseUrl = 'https://hentaifox.com'
overrideVersionCode = 6
overrideVersionCode = 7
isNsfw = true
}

View File

@ -143,6 +143,7 @@ class HentaiFox(
}
return xhrHeaders.newBuilder()
.add("X-Csrf-Token", csrfToken)
.add("Referer", "$baseUrl/")
.build()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,117 +0,0 @@
package eu.kanade.tachiyomi.extension.all.kanjiku
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
class Kanjiku(
override val lang: String,
subDomain: String,
) : ParsedHttpSource() {
override val name = "Kanjiku"
override val baseUrl = "https://${subDomain}kanjiku.net"
override val supportsLatest = true
override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/mangas", headers)
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/latest", headers)
override fun popularMangaSelector(): String = ".manga_box"
override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply {
setUrlWithoutDomain(element.absUrl("href"))
title = element.selectFirst(".manga_title")!!.text()
thumbnail_url = element.selectFirst("img")?.absUrl("src")
}
override fun latestUpdatesParse(response: Response): MangasPage {
val mangas = response.asJsoup().select(".manga_overview_box_headline a").map { element ->
SManga.create().apply {
var url = element.absUrl("href").toHttpUrl()
if (url.pathSegments.last() == "") {
// remove empty path segment
url = url.newBuilder().removePathSegment(url.pathSegments.lastIndex).build()
}
setUrlWithoutDomain(url.toString())
title = element.text()
}
}.distinctBy { it.url }
return MangasPage(mangas, false)
}
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return Observable.just(
MangasPage(
client.newCall(popularMangaRequest(page)).execute().asJsoup()
.select(popularMangaSelector()).map { popularMangaFromElement(it) }
.filter { query.lowercase() in it.title.lowercase() },
false,
),
)
}
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
title = document.selectFirst(".manga_page_title")!!.text()
description = document.selectFirst(".manga_description")?.text()
thumbnail_url = document.selectFirst(".manga_page_picture")?.absUrl("src")
status = when (
document.selectFirst(".tags .tag_container_special .tag")?.absUrl("href")
?.toHttpUrl()?.pathSegments?.last()
) {
"47" -> SManga.ONGOING
"48" -> SManga.COMPLETED
"49" -> SManga.ON_HIATUS
"50" -> SManga.CANCELLED
"51" -> SManga.LICENSED
else -> SManga.UNKNOWN // using tag ids so that it works in all languages
}
genre = document.select(".tags .tag_container .tag").joinToString { it.text() }
}
override fun chapterListSelector(): String = ".manga_chapter a"
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
setUrlWithoutDomain(
element.absUrl("href").toHttpUrl().run {
newBuilder().setPathSegment(pathSegments.lastIndex, "0").build()
}.toString(),
)
name = element.text()
}
override fun pageListParse(document: Document): List<Page> =
document.select(".container img").mapIndexed { index, element ->
Page(index, imageUrl = element.absUrl("src"))
}
override fun latestUpdatesFromElement(element: Element): SManga =
throw UnsupportedOperationException()
override fun searchMangaFromElement(element: Element): SManga =
throw UnsupportedOperationException()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
throw UnsupportedOperationException()
override fun searchMangaSelector(): String = throw UnsupportedOperationException()
override fun latestUpdatesSelector(): String = throw UnsupportedOperationException()
override fun searchMangaNextPageSelector(): String? = null
override fun popularMangaNextPageSelector(): String? = null
override fun latestUpdatesNextPageSelector(): String? = null
override fun imageUrlParse(document: Document): String = ""
}

View File

@ -1,11 +0,0 @@
package eu.kanade.tachiyomi.extension.all.kanjiku
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class KanjikuFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
Kanjiku("de", ""),
Kanjiku("en", "eng."),
)
}

View File

@ -1,7 +1,7 @@
ext {
extName = 'SchaleNetwork'
extClass = '.KoharuFactory'
extVersionCode = 15
extVersionCode = 17
isNsfw = true
}

View File

@ -7,6 +7,7 @@ import android.os.Handler
import android.os.Looper
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
@ -57,13 +58,23 @@ class Koharu(
private val searchLang: String = "",
) : HttpSource(), ConfigurableSource {
private val preferences: SharedPreferences by getPreferencesLazy()
override val name = "SchaleNetwork"
override val baseUrl = "https://schale.network"
override val baseUrl: String
get() {
val preferenceValue = preferences.getString(PREF_MIRROR, MIRROR_PREF_DEFAULT) ?: MIRROR_PREF_DEFAULT
val mirror = preferenceValue.toIntOrNull()?.let { index ->
mirrors[index.coerceAtMost(mirrors.lastIndex)]
} ?: preferenceValue.takeIf { it in mirrors } ?: MIRROR_PREF_DEFAULT
return "https://$mirror"
}
override val id = if (lang == "en") 1484902275639232927 else super.id
private val apiUrl = baseUrl.replace("://", "://api.")
private val apiUrl = API_DOMAIN
private val apiBooksUrl = "$apiUrl/books"
@ -74,8 +85,6 @@ class Koharu(
private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
private fun String.shortenTitle() = replace(shortenTitleRegex, "").trim()
private val preferences: SharedPreferences by getPreferencesLazy()
private fun quality() = preferences.getString(PREF_IMAGERES, "1280")!!
private fun remadd() = preferences.getBoolean(PREF_REM_ADD, false)
@ -457,6 +466,20 @@ class Koharu(
// Settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_MIRROR
title = "Preferred Mirror"
entries = mirrors
entryValues = mirrors
setDefaultValue(MIRROR_PREF_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, "Restart the app to apply changes", Toast.LENGTH_LONG).show()
true
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_IMAGERES
title = "Image Resolution"
@ -488,6 +511,16 @@ class Koharu(
companion object {
const val PREFIX_ID_KEY_SEARCH = "id:"
private const val PREF_MIRROR = "pref_mirror"
private const val MIRROR_PREF_DEFAULT = "schale.network"
private const val API_DOMAIN = "https://api.schale.network"
private val mirrors = arrayOf(
MIRROR_PREF_DEFAULT,
"anchira.to",
"gehenna.jp",
"niyaniya.moe",
"shupogaki.moe",
)
private const val PREF_IMAGERES = "pref_image_quality"
private const val PREF_REM_ADD = "pref_remove_additional"
private const val PREF_EXCLUDE_TAGS = "pref_exclude_tags"

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.luscious.LusciousUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="www.luscious.net"
android:pathPattern="/albums/..*"
android:scheme="https" />
<data
android:host="members.luscious.net"
android:pathPattern="/albums/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,7 +1,7 @@
ext {
extName = 'Luscious'
extClass = '.LusciousFactory'
extVersionCode = 22
extVersionCode = 23
isNsfw = true
}

View File

@ -29,6 +29,7 @@ import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
@ -55,6 +56,11 @@ abstract class Luscious(
private val json: Json by injectLazy()
override fun headersBuilder(): Headers.Builder {
return super.headersBuilder()
.add("Referer", "$baseUrl/")
}
override val client: OkHttpClient
get() = network.cloudflareClient.newBuilder()
.addNetworkInterceptor(rewriteOctetStream)
@ -487,6 +493,12 @@ abstract class Luscious(
client.newCall(buildAlbumInfoRequest(id))
.asObservableSuccess()
.map { MangasPage(listOf(detailsParse(it)), false) }
} else if (query.startsWith("ALBUM:")) {
val album = query.substringAfterLast("ALBUM:")
val id = album.split("_").last()
client.newCall(buildAlbumInfoRequest(id))
.asObservableSuccess()
.map { MangasPage(listOf(detailsParse(it)), false) }
} else {
super.fetchSearchManga(page, query, filters)
}

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.extension.all.luscious
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://www.luscious.net/albums/xxxxxx intents and redirects them to
* the main Tachiyomi process.
*/
class LusciousUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val album = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "ALBUM:$album")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("LusciousUrlActivity", e.toString())
}
} else {
Log.e("LusciousUrlActivity", "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.mangaball.UrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:host="mangaball.net" />
<data android:scheme="https" />
<data android:pathPattern="/title-detail/..*" />
<data android:pathPattern="/chapter-detail/..*" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'Manga Ball'
extClass = '.MangaBallFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,71 @@
package eu.kanade.tachiyomi.extension.all.mangaball
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class SearchResponse(
val data: List<SearchManga>,
private val pagination: Pagination,
) {
@Serializable
class Pagination(
@SerialName("current_page")
val currentPage: Int,
@SerialName("last_page")
val lastPage: Int,
)
fun hasNextPage() = pagination.currentPage < pagination.lastPage
}
@Serializable
class SearchManga(
val url: String,
val name: String,
val cover: String,
val isAdult: Boolean,
)
@Serializable
class ChapterListResponse(
@SerialName("ALL_CHAPTERS")
val chapters: List<ChapterContainer>,
)
@Serializable
class ChapterContainer(
@SerialName("number_float")
val number: Float,
val translations: List<Chapter>,
)
@Serializable
class Chapter(
val id: String,
val name: String,
val language: String,
val group: Group,
val date: String,
val volume: Int,
)
@Serializable
class Group(
@SerialName("_id")
val id: String,
val name: String,
)
@Serializable
class Yoast(
@SerialName("@graph")
val graph: List<Graph>,
) {
@Serializable
class Graph(
@SerialName("@type")
val type: String,
val url: String? = null,
)
}

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