From 316ae2f9dbcbd6bb4004c79f5ea4503d202ac484 Mon Sep 17 00:00:00 2001 From: achmad Date: Wed, 13 May 2026 23:11:26 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20phase=204=20batch=20?= =?UTF-8?q?=E2=80=94=2054=20base-class=20wrapper=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 8 all/ sources (7 Masonry, 1 Madara) and 38 en/ sources spanning Madara, MangaThemesia, MadTheme, Keyoapp, and Guya bases, plus 8 earlier all/ standalone sources from the previous session (ahottie, akuma, allporncomicsco, asmhentai, baobua, beauty3600000, buondua, comicfury, comicgrowl, comicklive, comicsvalley, comikey, commitstrip, coomer). Also annotates phase4-standalone.md with base-class tags for 43 additional unimplemented en/ sources identified in a full scan. --- cmd/server/main.go | 60 +++ docs/phase4-standalone.md | 230 +++++----- sources/all/ahottie/ahottie.go | 221 +++++++++ sources/all/akuma/akuma.go | 339 ++++++++++++++ .../all/allporncomicsco/allporncomicsco.go | 21 + sources/all/asmhentai/asmhentai.go | 238 ++++++++++ sources/all/baobua/baobua.go | 250 ++++++++++ sources/all/beauty3600000/beauty3600000.go | 319 +++++++++++++ sources/all/buondua/buondua.go | 249 ++++++++++ sources/all/comicfury/comicfury.go | 366 +++++++++++++++ sources/all/comicgrowl/comicgrowl.go | 18 + sources/all/comicklive/comicklive.go | 426 ++++++++++++++++++ sources/all/comicsvalley/comicsvalley.go | 20 + sources/all/comikey/comikey.go | 381 ++++++++++++++++ sources/all/commitstrip/commitstrip.go | 223 +++++++++ sources/all/coomer/coomer.go | 18 + sources/all/elitebabes/elitebabes.go | 16 + sources/all/femjoyhunter/femjoyhunter.go | 16 + sources/all/ftvhunter/ftvhunter.go | 16 + sources/all/joymiihub/joymiihub.go | 16 + sources/all/mangacrazy/mangacrazy.go | 17 + sources/all/metarthunter/metarthunter.go | 16 + sources/all/playmatehunter/playmatehunter.go | 16 + sources/all/xarthunter/xarthunter.go | 16 + sources/en/aquamanga/aquamanga.go | 16 + sources/en/arenascans/arenascans.go | 16 + sources/en/artlapsa/artlapsa.go | 16 + sources/en/cocomic/cocomic.go | 17 + sources/en/comicsland/comicsland.go | 16 + sources/en/crowscans/crowscans.go | 16 + sources/en/dankefurslesen/dankefurslesen.go | 16 + sources/en/elftoon/elftoon.go | 16 + sources/en/gakamangas/gakamangas.go | 17 + .../en/galaxydegenscans/galaxydegenscans.go | 16 + sources/en/hachirumi/hachirumi.go | 16 + sources/en/hentai4free/hentai4free.go | 18 + sources/en/jinmangas/jinmangas.go | 17 + sources/en/kaliscanme/kaliscanme.go | 16 + sources/en/kingofshojo/kingofshojo.go | 16 + sources/en/kissmangain/kissmangain.go | 17 + sources/en/kunmanga/kunmanga.go | 16 + sources/en/lhtranslation/lhtranslation.go | 17 + sources/en/manga18x/manga18x.go | 17 + sources/en/mangagg/mangagg.go | 16 + sources/en/mangahe/mangahe.go | 16 + sources/en/mangahentai/mangahentai.go | 17 + sources/en/manhwalover/manhwalover.go | 16 + sources/en/manhwareads/manhwareads.go | 17 + sources/en/manhwatop/manhwatop.go | 16 + sources/en/manhwax/manhwax.go | 16 + sources/en/mgjinx/mgjinx.go | 16 + sources/en/octopusmanga/octopusmanga.go | 17 + sources/en/pawmanga/pawmanga.go | 17 + sources/en/ravenscans/ravenscans.go | 16 + sources/en/ritharscans/ritharscans.go | 16 + sources/en/rosesquadscans/rosesquadscans.go | 17 + sources/en/ryumanga/ryumanga.go | 16 + .../tcbscansunoriginal/tcbscansunoriginal.go | 16 + sources/en/toonilyme/toonilyme.go | 16 + sources/en/toonitube/toonitube.go | 16 + sources/en/topmanhua/topmanhua.go | 17 + sources/en/yaoihub/yaoihub.go | 17 + 62 files changed, 4027 insertions(+), 104 deletions(-) create mode 100644 sources/all/ahottie/ahottie.go create mode 100644 sources/all/akuma/akuma.go create mode 100644 sources/all/allporncomicsco/allporncomicsco.go create mode 100644 sources/all/asmhentai/asmhentai.go create mode 100644 sources/all/baobua/baobua.go create mode 100644 sources/all/beauty3600000/beauty3600000.go create mode 100644 sources/all/buondua/buondua.go create mode 100644 sources/all/comicfury/comicfury.go create mode 100644 sources/all/comicgrowl/comicgrowl.go create mode 100644 sources/all/comicklive/comicklive.go create mode 100644 sources/all/comicsvalley/comicsvalley.go create mode 100644 sources/all/comikey/comikey.go create mode 100644 sources/all/commitstrip/commitstrip.go create mode 100644 sources/all/coomer/coomer.go create mode 100644 sources/all/elitebabes/elitebabes.go create mode 100644 sources/all/femjoyhunter/femjoyhunter.go create mode 100644 sources/all/ftvhunter/ftvhunter.go create mode 100644 sources/all/joymiihub/joymiihub.go create mode 100644 sources/all/mangacrazy/mangacrazy.go create mode 100644 sources/all/metarthunter/metarthunter.go create mode 100644 sources/all/playmatehunter/playmatehunter.go create mode 100644 sources/all/xarthunter/xarthunter.go create mode 100644 sources/en/aquamanga/aquamanga.go create mode 100644 sources/en/arenascans/arenascans.go create mode 100644 sources/en/artlapsa/artlapsa.go create mode 100644 sources/en/cocomic/cocomic.go create mode 100644 sources/en/comicsland/comicsland.go create mode 100644 sources/en/crowscans/crowscans.go create mode 100644 sources/en/dankefurslesen/dankefurslesen.go create mode 100644 sources/en/elftoon/elftoon.go create mode 100644 sources/en/gakamangas/gakamangas.go create mode 100644 sources/en/galaxydegenscans/galaxydegenscans.go create mode 100644 sources/en/hachirumi/hachirumi.go create mode 100644 sources/en/hentai4free/hentai4free.go create mode 100644 sources/en/jinmangas/jinmangas.go create mode 100644 sources/en/kaliscanme/kaliscanme.go create mode 100644 sources/en/kingofshojo/kingofshojo.go create mode 100644 sources/en/kissmangain/kissmangain.go create mode 100644 sources/en/kunmanga/kunmanga.go create mode 100644 sources/en/lhtranslation/lhtranslation.go create mode 100644 sources/en/manga18x/manga18x.go create mode 100644 sources/en/mangagg/mangagg.go create mode 100644 sources/en/mangahe/mangahe.go create mode 100644 sources/en/mangahentai/mangahentai.go create mode 100644 sources/en/manhwalover/manhwalover.go create mode 100644 sources/en/manhwareads/manhwareads.go create mode 100644 sources/en/manhwatop/manhwatop.go create mode 100644 sources/en/manhwax/manhwax.go create mode 100644 sources/en/mgjinx/mgjinx.go create mode 100644 sources/en/octopusmanga/octopusmanga.go create mode 100644 sources/en/pawmanga/pawmanga.go create mode 100644 sources/en/ravenscans/ravenscans.go create mode 100644 sources/en/ritharscans/ritharscans.go create mode 100644 sources/en/rosesquadscans/rosesquadscans.go create mode 100644 sources/en/ryumanga/ryumanga.go create mode 100644 sources/en/tcbscansunoriginal/tcbscansunoriginal.go create mode 100644 sources/en/toonilyme/toonilyme.go create mode 100644 sources/en/toonitube/toonitube.go create mode 100644 sources/en/topmanhua/topmanhua.go create mode 100644 sources/en/yaoihub/yaoihub.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 819f9c3..c9a368e 100755 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,28 @@ import ( "goyomi/internal/config" "goyomi/internal/db" _ "goyomi/internal/registry" + _ "goyomi/sources/all/ahottie" + _ "goyomi/sources/all/akuma" + _ "goyomi/sources/all/allporncomicsco" + _ "goyomi/sources/all/asmhentai" + _ "goyomi/sources/all/baobua" + _ "goyomi/sources/all/beauty3600000" + _ "goyomi/sources/all/buondua" + _ "goyomi/sources/all/comicfury" + _ "goyomi/sources/all/comicgrowl" + _ "goyomi/sources/all/comicklive" + _ "goyomi/sources/all/comikey" + _ "goyomi/sources/all/comicsvalley" + _ "goyomi/sources/all/commitstrip" + _ "goyomi/sources/all/coomer" + _ "goyomi/sources/all/elitebabes" + _ "goyomi/sources/all/femjoyhunter" + _ "goyomi/sources/all/ftvhunter" + _ "goyomi/sources/all/joymiihub" + _ "goyomi/sources/all/mangacrazy" + _ "goyomi/sources/all/metarthunter" + _ "goyomi/sources/all/playmatehunter" + _ "goyomi/sources/all/xarthunter" _ "goyomi/sources/all/hentaihand" _ "goyomi/sources/all/kemono" _ "goyomi/sources/all/mangataro" @@ -29,6 +51,44 @@ import ( _ "goyomi/sources/en/sanascans" _ "goyomi/sources/en/sirenscans" _ "goyomi/sources/en/vanillascans" + _ "goyomi/sources/en/aquamanga" + _ "goyomi/sources/en/arenascans" + _ "goyomi/sources/en/artlapsa" + _ "goyomi/sources/en/cocomic" + _ "goyomi/sources/en/comicsland" + _ "goyomi/sources/en/crowscans" + _ "goyomi/sources/en/dankefurslesen" + _ "goyomi/sources/en/elftoon" + _ "goyomi/sources/en/gakamangas" + _ "goyomi/sources/en/galaxydegenscans" + _ "goyomi/sources/en/hachirumi" + _ "goyomi/sources/en/hentai4free" + _ "goyomi/sources/en/jinmangas" + _ "goyomi/sources/en/kaliscanme" + _ "goyomi/sources/en/kingofshojo" + _ "goyomi/sources/en/kissmangain" + _ "goyomi/sources/en/kunmanga" + _ "goyomi/sources/en/lhtranslation" + _ "goyomi/sources/en/manga18x" + _ "goyomi/sources/en/mangagg" + _ "goyomi/sources/en/mangahe" + _ "goyomi/sources/en/mangahentai" + _ "goyomi/sources/en/manhwalover" + _ "goyomi/sources/en/manhwareads" + _ "goyomi/sources/en/manhwatop" + _ "goyomi/sources/en/manhwax" + _ "goyomi/sources/en/mgjinx" + _ "goyomi/sources/en/octopusmanga" + _ "goyomi/sources/en/pawmanga" + _ "goyomi/sources/en/ravenscans" + _ "goyomi/sources/en/ritharscans" + _ "goyomi/sources/en/rosesquadscans" + _ "goyomi/sources/en/ryumanga" + _ "goyomi/sources/en/tcbscansunoriginal" + _ "goyomi/sources/en/toonilyme" + _ "goyomi/sources/en/toonitube" + _ "goyomi/sources/en/topmanhua" + _ "goyomi/sources/en/yaoihub" ) func main() { diff --git a/docs/phase4-standalone.md b/docs/phase4-standalone.md index 64fb55e..ebd49d2 100755 --- a/docs/phase4-standalone.md +++ b/docs/phase4-standalone.md @@ -3,6 +3,28 @@ Complete port checklist. Check a box when the source passes a basic smoke test (popular/latest list returns ≥1 result, or detail+pages resolve for a known URL). +**After implementing each source:** mark its checkbox `[x]` in this doc and add it to the import list in `cmd/server/main.go`. + +## Important: Source Types + +### Base Sources (`lib-multisrc`) +Shared implementations in: +- `/Users/achmad/Documents/Belajar/Android/extensions-source/lib-multisrc/` +- `/Users/achmad/Documents/Belajar/Web/goyomi/sources/base/` + +These are the core implementations that standalone sources may use. + +### Standalone Sources (`all/` and `en/`) +Individual extensions in: +- `/Users/achmad/Documents/Belajar/Android/extensions-source/src/all/` +- `/Users/achmad/Documents/Belajar/Android/extensions-source/src/en/` + +These may or may not use a base source from lib-multisrc — and even when they do, they often add their own +implementation on top (overriding methods, adding extra parsing, custom filters, etc.). +When porting, check if the Kotlin source extends a base class (e.g., `MangaReader`, `Madara`, `ComicGeek`) — +if so, use the corresponding Go base implementation as the foundation, then layer the source-specific overrides. +Otherwise, implement fully standalone. + Reference: - `/Users/achmad/Documents/Belajar/Android/extensions-source/src/all/` - `/Users/achmad/Documents/Belajar/Android/extensions-source/src/en/` @@ -13,21 +35,21 @@ Detailed implementation notes for complex sources are in the **Notes** section a ## `sources/all/` — 125 sources -- [ ] `all/ahottie` -- [ ] `all/akuma` -- [ ] `all/allporncomicsco` -- [ ] `all/asmhentai` -- [ ] `all/baobua` -- [ ] `all/beauty3600000` -- [ ] `all/buondua` -- [ ] `all/comicfury` -- [ ] `all/comicgrowl` -- [ ] `all/comicklive` -- [ ] `all/comicskingdom` -- [ ] `all/comicsvalley` -- [ ] `all/comikey` -- [ ] `all/commitstrip` -- [ ] `all/coomer` +- [x] `all/ahottie` +- [x] `all/akuma` +- [x] `all/allporncomicsco` +- [x] `all/asmhentai` +- [x] `all/baobua` +- [x] `all/beauty3600000` +- [x] `all/buondua` +- [x] `all/comicfury` +- [x] `all/comicgrowl` +- [x] `all/comicklive` +- [ ] `all/comicskingdom` ⚠️ no Kotlin reference found — skip +- [x] `all/comicsvalley` +- [x] `all/comikey` +- [x] `all/commitstrip` +- [x] `all/coomer` - [ ] `all/coronaex` - [ ] `all/cosplaytele` - [ ] `all/cubari` @@ -35,14 +57,14 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `all/deviantart` - [ ] `all/dragonballmultiverse` - [ ] `all/e621` ⚠️ see notes -- [ ] `all/elitebabes` +- [x] `all/elitebabes` - [ ] `all/everiaclub` - [ ] `all/everiaclubcom` -- [ ] `all/femjoyhunter` +- [x] `all/femjoyhunter` - [ ] `all/foamgirl` - [ ] `all/foolslidecustomizable` - [ ] `all/fourkhd` -- [ ] `all/ftvhunter` +- [x] `all/ftvhunter` - [ ] `all/fuwayomi` - [ ] `all/globalcomix` ⚠️ see notes - [ ] `all/grabberzone` @@ -62,7 +84,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `all/imhentai` - [ ] `all/izneo` - [ ] `all/jjcos` -- [ ] `all/joymiihub` +- [x] `all/joymiihub` - [ ] `all/junmeitu` - [x] `all/kemono` ⚠️ see notes - [ ] `all/kiutaku` @@ -76,7 +98,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `all/magicaltranslators` - [ ] `all/manga18me` - [ ] `all/mangaball` -- [ ] `all/mangacrazy` +- [x] `all/mangacrazy` - [ ] `all/mangadex` ⚠️ see notes - [ ] `all/mangadraft` - [ ] `all/mangafire` @@ -94,7 +116,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `all/manhwaclubnet` - [ ] `all/manhwadashraw` - [ ] `all/mayotune` -- [ ] `all/metarthunter` +- [x] `all/metarthunter` - [ ] `all/miauscan` - [ ] `all/misskon` - [ ] `all/mitaku` @@ -110,7 +132,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `all/peppercarrot` - [ ] `all/photos18` - [ ] `all/pixiv` ⚠️ see notes -- [ ] `all/playmatehunter` +- [x] `all/playmatehunter` - [ ] `all/pornpics` - [ ] `all/projectsuki` - [ ] `all/qtoon` @@ -130,7 +152,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `all/uncensoredmanhwa` - [ ] `all/vinnieVeritas` - [ ] `all/webtoons` ⚠️ see notes -- [ ] `all/xarthunter` +- [x] `all/xarthunter` - [ ] `all/xasiatalbums` - [ ] `all/xgmn` - [ ] `all/xinmeitulu` @@ -152,11 +174,11 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/allporncomicio` - [ ] `en/anisascans` - [ ] `en/apcomics` -- [ ] `en/aquamanga` -- [ ] `en/arcrelight` -- [ ] `en/arenascans` +- [x] `en/aquamanga` +- [ ] `en/arcrelight` → base: Madara +- [x] `en/arenascans` - [ ] `en/armageddon` -- [ ] `en/artlapsa` +- [x] `en/artlapsa` - [ ] `en/arvencomics` - [ ] `en/arvenscans` - [ ] `en/aryascans` @@ -165,27 +187,27 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/assortedscans` - [ ] `en/asurascans` ⚠️ see notes - [ ] `en/athreascans` -- [ ] `en/atsumaru` -- [ ] `en/aurora` -- [ ] `en/azcomic` +- [ ] `en/atsumaru` → base: Madara +- [ ] `en/aurora` → base: Madara +- [ ] `en/azcomic` → base: Madara - [ ] `en/azuki` - [ ] `en/baektoons` - [x] `en/bakkin` ⚠️ see notes - [ ] `en/bakkinselfhosted` - [ ] `en/batcave` - [ ] `en/battleinfivesecondsaftermeeting` -- [ ] `en/beehentai` +- [ ] `en/beehentai` → base: MadTheme - [ ] `en/bookwalker` ⚠️ see notes -- [ ] `en/boratscans` -- [ ] `en/boxmanhwa` -- [ ] `en/broccolisoup` -- [ ] `en/bunmanga` -- [ ] `en/buttsmithy` -- [ ] `en/clonemanga` -- [ ] `en/clowncorps` +- [ ] `en/boratscans` → base: Madara +- [ ] `en/boxmanhwa` → base: MadTheme +- [ ] `en/broccolisoup` → base: Madara +- [ ] `en/bunmanga` → base: Madara +- [ ] `en/buttsmithy` → base: Madara +- [ ] `en/clonemanga` → base: Madara +- [ ] `en/clowncorps` → base: Madara - [ ] `en/cmanhua` -- [ ] `en/cocomic` -- [ ] `en/coffeemanga` +- [x] `en/cocomic` +- [ ] `en/coffeemanga` → base: Madara (complex) - [ ] `en/collectedcurios` - [ ] `en/comicasura` - [ ] `en/comiccx` @@ -193,19 +215,19 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/comickfan` - [ ] `en/comickiba` - [ ] `en/comicland` -- [ ] `en/comicsland` +- [x] `en/comicsland` - [ ] `en/comix` -- [ ] `en/crowscans` -- [ ] `en/cucumbermanga` +- [x] `en/crowscans` +- [ ] `en/cucumbermanga` → base: Madara - [ ] `en/culturedworks` - [ ] `en/cutiecomics` -- [ ] `en/dankefurslesen` +- [x] `en/dankefurslesen` - [ ] `en/darklegacycomics` - [ ] `en/darkscans` - [ ] `en/darkscience` - [ ] `en/darthsdroids` - [ ] `en/deathtollscans` -- [ ] `en/decadencescans` +- [ ] `en/decadencescans` → base: Madara - [ ] `en/dflowscans` - [ ] `en/digitalcomicmuseum` - [x] `en/divascans` @@ -219,7 +241,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/eighteenporncomic` - [ ] `en/eightmuses` - [ ] `en/elanschool` -- [ ] `en/elftoon` +- [x] `en/elftoon` - [ ] `en/epicmanga` - [ ] `en/erisscans` - [ ] `en/ero18x` @@ -232,11 +254,11 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/ezmanga` - [ ] `en/fablescans` - [ ] `en/fairyscans` -- [ ] `en/firescans` +- [ ] `en/firescans` → base: Madara (complex) - [ ] `en/flamecomics` - [ ] `en/frierenonline` -- [ ] `en/gakamangas` -- [ ] `en/galaxydegenscans` +- [x] `en/gakamangas` +- [x] `en/galaxydegenscans` - [ ] `en/galaxymanga` - [ ] `en/gedecomix` - [ ] `en/gingertoon` @@ -248,16 +270,16 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/gunnerkriggcourt` - [x] `en/guya` ⚠️ see notes - [ ] `en/gwtb` -- [ ] `en/hachirumi` +- [x] `en/hachirumi` - [ ] `en/hadesscans` - [ ] `en/harimanga` - [ ] `en/hentai3zcc` -- [ ] `en/hentai4free` +- [x] `en/hentai4free` - [ ] `en/hentaidex` - [ ] `en/hentaihere` - [ ] `en/hentaikun` - [ ] `en/hentainexus` -- [ ] `en/hentairead` +- [ ] `en/hentairead` → base: Madara (complex) - [ ] `en/hentaireadio` - [ ] `en/hentaisco` - [ ] `en/hentaixcomic` @@ -276,13 +298,13 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/infinityscans` - [ ] `en/irovedout` - [ ] `en/isekaiscantop` -- [ ] `en/jinmangas` +- [x] `en/jinmangas` - [ ] `en/jnovel` - [ ] `en/kagane` - [x] `en/kaizenscan` -- [ ] `en/kaliscancom` -- [ ] `en/kaliscanio` -- [ ] `en/kaliscanme` +- [ ] `en/kaliscancom` → base: MadTheme +- [ ] `en/kaliscanio` → base: MadTheme +- [x] `en/kaliscanme` - [ ] `en/kappabeast` - [ ] `en/kaynscans` - [ ] `en/keenspot` @@ -290,20 +312,20 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [x] `en/kewnscans` - [ ] `en/killsixbilliondemons` - [ ] `en/kingcomix` -- [ ] `en/kingofshojo` -- [ ] `en/kissmangain` +- [x] `en/kingofshojo` +- [x] `en/kissmangain` - [ ] `en/kmanga` - [ ] `en/kodansha` - [ ] `en/ksgroupscans` -- [ ] `en/kunmanga` +- [x] `en/kunmanga` - [ ] `en/kuramanga` - [ ] `en/lagoonscans` - [ ] `en/leslievictims` -- [ ] `en/lhtranslation` +- [x] `en/lhtranslation` - [ ] `en/likemanga` - [ ] `en/likemangain` - [ ] `en/lilymanga` -- [ ] `en/linkmanga` +- [ ] `en/linkmanga` → base: Madara - [ ] `en/loadingartist` - [ ] `en/luascans` - [ ] `en/luminaretranslations` @@ -316,16 +338,16 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/manga18club` - [ ] `en/manga18free` - [ ] `en/manga18fx` -- [ ] `en/manga18x` +- [x] `en/manga18x` - [ ] `en/mangabat` - [ ] `en/mangablaze` - [ ] `en/mangabolt` - [ ] `en/mangabtt` -- [ ] `en/mangabuddy` -- [ ] `en/mangabuddyme` +- [ ] `en/mangabuddy` → base: MadTheme +- [ ] `en/mangabuddyme` → base: MadTheme - [ ] `en/mangaclash` - [ ] `en/mangacloud` -- [ ] `en/mangacute` +- [ ] `en/mangacute` → base: MadTheme - [ ] `en/mangadass` - [ ] `en/mangade` - [ ] `en/mangademon` @@ -333,64 +355,64 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/mangadistrict` - [ ] `en/mangadotnet` - [ ] `en/mangadrama` -- [ ] `en/mangafab` -- [ ] `en/mangaforest` +- [ ] `en/mangafab` → base: MadTheme +- [ ] `en/mangaforest` → base: MadTheme - [ ] `en/mangaforfreecom` - [ ] `en/mangafox` -- [ ] `en/mangafoxfun` +- [ ] `en/mangafoxfun` → base: MangaHub - [ ] `en/mangafreak` - [ ] `en/mangafree` -- [ ] `en/mangagg` +- [x] `en/mangagg` - [ ] `en/mangago` - [ ] `en/mangagofun` -- [ ] `en/mangahe` +- [x] `en/mangahe` - [ ] `en/mangahen` -- [ ] `en/mangahentai` +- [x] `en/mangahentai` - [ ] `en/mangahere` -- [ ] `en/mangahereonl` -- [ ] `en/mangahubio` +- [ ] `en/mangahereonl` → base: MangaHub +- [ ] `en/mangahubio` → base: MangaHub - [ ] `en/mangaka` - [ ] `en/mangakakalot` -- [ ] `en/mangakakalotfun` +- [ ] `en/mangakakalotfun` → base: MangaHub - [ ] `en/mangakatana` - [ ] `en/mangakiss` - [ ] `en/mangamaniacs` - [ ] `en/mangamo` - [ ] `en/mangamob` -- [ ] `en/mangamonk` -- [ ] `en/manganel` +- [ ] `en/mangamonk` → base: MadTheme +- [ ] `en/manganel` → base: MangaHub - [ ] `en/manganelo` - [ ] `en/manganow` -- [ ] `en/mangaonlinefun` +- [ ] `en/mangaonlinefun` → base: MangaHub - [ ] `en/mangaowlio` -- [ ] `en/mangapandaonl` +- [ ] `en/mangapandaonl` → base: MangaHub - [ ] `en/mangapill` - [ ] `en/mangapuma` - [ ] `en/mangarawclub` - [ ] `en/mangaread` - [ ] `en/mangareadercc` -- [ ] `en/mangareadersite` +- [ ] `en/mangareadersite` → base: MangaHub - [ ] `en/mangareadorg` -- [ ] `en/mangasaga` +- [ ] `en/mangasaga` → base: MadTheme - [ ] `en/mangasect` -- [ ] `en/mangaspin` +- [ ] `en/mangaspin` → base: MadTheme - [ ] `en/mangasushi` - [ ] `en/mangatellers` -- [ ] `en/mangatoday` +- [ ] `en/mangatoday` → base: MangaHub - [ ] `en/mangatown` - [ ] `en/mangatrend` - [ ] `en/mangatx` -- [ ] `en/mangaxyz` +- [ ] `en/mangaxyz` → base: MadTheme - [ ] `en/manhuafast` - [ ] `en/manhuafastnet` - [ ] `en/manhuahot` - [ ] `en/manhuanext` -- [ ] `en/manhuanow` +- [ ] `en/manhuanow` → base: MadTheme - [ ] `en/manhuaplus` - [ ] `en/manhuaplusorg` - [ ] `en/manhuarush` - [ ] `en/manhuascanus` -- [ ] `en/manhuasite` +- [ ] `en/manhuasite` → base: MadTheme - [ ] `en/manhuatop` - [ ] `en/manhuaus` - [ ] `en/manhuazonghe` @@ -405,13 +427,13 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/manhwahub` - [ ] `en/manhwajoy` - [ ] `en/manhwalike` -- [ ] `en/manhwalover` +- [x] `en/manhwalover` - [ ] `en/manhwamanhua` - [ ] `en/manhwaread` -- [ ] `en/manhwareads` +- [x] `en/manhwareads` - [ ] `en/manhwatoon` -- [ ] `en/manhwatop` -- [ ] `en/manhwax` +- [x] `en/manhwatop` +- [x] `en/manhwax` - [ ] `en/manhwaxxl` - [ ] `en/manhwaz` - [ ] `en/manhwazone` @@ -419,7 +441,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/megatokyo` - [ ] `en/mehgazone` - [ ] `en/meitoon` -- [ ] `en/mgjinx` +- [x] `en/mgjinx` - [ ] `en/mgreadio` - [ ] `en/milftoon` - [x] `en/mistscans` @@ -444,12 +466,12 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [x] `en/nyanukafe` - [ ] `en/nyrascans` - [x] `en/nyxscans` -- [ ] `en/octopusmanga` +- [x] `en/octopusmanga` - [ ] `en/oglaf` - [ ] `en/ohjoysextoy` - [ ] `en/omegascans` -- [ ] `en/onemangaco` -- [ ] `en/onemangainfo` +- [ ] `en/onemangaco` → base: MangaHub +- [ ] `en/onemangainfo` → base: MangaHub - [ ] `en/onepunchmanonline` - [ ] `en/onlythebesthentai` - [ ] `en/oots` @@ -460,7 +482,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/paragonscans` - [ ] `en/paritehaber` - [ ] `en/patchfriday` -- [ ] `en/pawmanga` +- [x] `en/pawmanga` - [ ] `en/petrotechsociety` - [ ] `en/philiascans` - [ ] `en/plutoscans` @@ -470,7 +492,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/questionablecontent` - [ ] `en/ragescans` - [ ] `en/randowiz` -- [ ] `en/ravenscans` +- [x] `en/ravenscans` - [ ] `en/razure` - [ ] `en/rdscans` - [ ] `en/readallcomicscom` @@ -500,13 +522,13 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/retsu` - [ ] `en/revivalscans` - [ ] `en/rinkocomics` -- [ ] `en/ritharscans` +- [x] `en/ritharscans` - [ ] `en/rizzcomic` - [ ] `en/rizzcomicunoriginal` - [ ] `en/rokaricomics` - [ ] `en/roliascan` -- [ ] `en/rosesquadscans` -- [ ] `en/ryumanga` +- [x] `en/rosesquadscans` +- [x] `en/ryumanga` - [ ] `en/s2manga` - [x] `en/sanascans` - [ ] `en/saturdaymorningbreakfastcomics` @@ -516,7 +538,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/shojoscans` - [x] `en/sirenscans` - [ ] `en/skymanga` -- [ ] `en/sleepytranslations` +- [ ] `en/sleepytranslations` → base: MangaThemesia - [ ] `en/solarandsundry` - [ ] `en/spmanhwa` - [ ] `en/spyfakku` @@ -527,7 +549,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/swordscomic` - [ ] `en/tapastic` - [ ] `en/tcbscans` -- [ ] `en/tcbscansunoriginal` +- [x] `en/tcbscansunoriginal` - [ ] `en/teamshadowi` - [ ] `en/templescan` - [ ] `en/theblank` @@ -538,10 +560,10 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/toon18` - [ ] `en/toongod` - [ ] `en/toonily` -- [ ] `en/toonilyme` -- [ ] `en/toonitube` +- [x] `en/toonilyme` +- [x] `en/toonitube` - [ ] `en/toonizy` -- [ ] `en/topmanhua` +- [x] `en/topmanhua` - [ ] `en/topmanhuafan` - [ ] `en/topmanhuanet` - [ ] `en/tritiniascans` @@ -571,7 +593,7 @@ Detailed implementation notes for complex sources are in the **Notes** section a - [ ] `en/xoxocomics` - [ ] `en/yakshacomics` - [ ] `en/yaoihot` -- [ ] `en/yaoihub` +- [x] `en/yaoihub` - [ ] `en/yaoiscan` - [ ] `en/yaoitoon` - [ ] `en/yorai` diff --git a/sources/all/ahottie/ahottie.go b/sources/all/ahottie/ahottie.go new file mode 100644 index 0000000..279823c --- /dev/null +++ b/sources/all/ahottie/ahottie.go @@ -0,0 +1,221 @@ +package ahottie + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +type Source struct { + cfg Config + client *httpclient.Client + id int64 +} + +type Config struct { + Name string + BaseURL string + Lang string +} + +func New() *Source { + c := httpclient.NewClient(httpclient.WithRateLimit(1, 2)) + return &Source{ + cfg: Config{ + Name: "AHottie", + BaseURL: "https://ahottie.top", + Lang: "all", + }, + client: c, + id: source.GenerateSourceID("AHottie", "all"), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return false } + +func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.base()+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ahottie: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find("#main > div > div").Each(func(_ int, el *goquery.Selection) { + link := el.Find("a").First() + href := link.AttrOr("href", "") + if href == "" { + return + } + titleEl := el.Find("h2") + if titleEl.Length() == 0 { + return + } + m := source.SManga{ + URL: href, + Title: strings.TrimSpace(titleEl.Text()), + } + if img := el.Find(".relative img").First(); img.Length() > 0 { + m.ThumbnailURL = img.AttrOr("src", "") + } + var genres []string + el.Find(".flex a").Each(func(_ int, a *goquery.Selection) { + if t := strings.TrimSpace(a.Text()); t != "" { + genres = append(genres, t) + } + }) + m.Genre = strings.Join(genres, ", ") + if m.URL != "" && m.Title != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find("a[rel=next]").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s?page=%d", s.base(), page)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return source.MangasPage{}, fmt.Errorf("ahottie: latest updates not supported") +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + if strings.HasPrefix(query, "http") { + doc, err := s.get(context.Background(), query) + if err != nil { + return source.MangasPage{}, err + } + m := s.parseMangaDetails(doc) + m.URL = strings.TrimPrefix(query, s.base()) + return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil + } + doc, err := s.get(context.Background(), fmt.Sprintf("%s/search?kw=%s&page=%d", s.base(), query, page)) + if err != nil { + return source.MangasPage{}, err + } + if doc.Find("h1").Length() > 0 && doc.Find("div.pl-3 > a").Length() > 0 { + m := s.parseMangaDetails(doc) + m.URL = strings.TrimPrefix(doc.Find("div.pl-3 > a").First().AttrOr("href", ""), s.base()) + return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil + } + return s.parseMangas(doc), nil +} + +func (s *Source) parseMangaDetails(doc *goquery.Document) source.SManga { + result := source.SManga{} + titleEl := doc.Find("h1").First() + if titleEl.Length() > 0 { + result.Title = strings.TrimSpace(titleEl.Text()) + } + var genres []string + doc.Find("div.pl-3 > a").Each(func(_ int, a *goquery.Selection) { + if t := strings.TrimSpace(a.Text()); t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + result.Status = source.StatusUnknown + return result +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), s.base()+manga.URL) + if err != nil { + return manga, err + } + result := s.parseMangaDetails(doc) + result.URL = manga.URL + if result.Title == "" { + result.Title = manga.Title + } + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), s.base()+manga.URL) + if err != nil { + return nil, err + } + timeEl := doc.Find("time").First() + var date int64 + if dateStr := strings.TrimSpace(timeEl.Text()); dateStr != "" { + t, err := time.Parse("2006-01-02", dateStr) + if err == nil { + date = t.UnixMilli() + } + } + canonical := doc.Find("link[rel=canonical]").First() + href := canonical.AttrOr("href", "") + if href == "" { + return nil, fmt.Errorf("ahottie: chapter link not found") + } + return []source.SChapter{ + { + URL: strings.TrimPrefix(href, s.base()), + Name: "GALLERY", + DateUpload: date, + }, + }, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + var pages []source.Page + doc, err := s.get(context.Background(), s.base()+chapter.URL) + if err != nil { + return nil, err + } + for { + doc.Find("#main img.block").Each(func(i int, img *goquery.Selection) { + src := img.AttrOr("src", "") + if src != "" { + pages = append(pages, source.Page{Index: len(pages), ImageURL: src}) + } + }) + nextURL := doc.Find("a[rel=next]").First().AttrOr("href", "") + if nextURL == "" { + break + } + doc, err = s.get(context.Background(), nextURL) + if err != nil { + break + } + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func init() { + registry.Register(New()) +} \ No newline at end of file diff --git a/sources/all/akuma/akuma.go b/sources/all/akuma/akuma.go new file mode 100644 index 0000000..d968d9d --- /dev/null +++ b/sources/all/akuma/akuma.go @@ -0,0 +1,339 @@ +// Package akuma implements the Akuma manga base. +// FlareSolverr + CSRF token interceptor; POST with form for list; single chapter per manga. +package akuma + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +type Source struct { + cfg Config + client *flare.Client + id int64 + nextHash string + csrfToken string +} + +type Config struct { + Name string + BaseURL string + Lang string +} + +func New() *Source { + c := flare.NewClient(flare.WithRateLimit(2, 1)) + return &Source{ + cfg: Config{ + Name: "Akuma", + BaseURL: "https://akuma.moe", + Lang: "all", + }, + client: c, + id: source.GenerateSourceID("Akuma", "all"), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.cfg.Name } +func (s *Source) Lang() string { return s.cfg.Lang } +func (s *Source) SupportsLatest() bool { return false } + +func (s *Source) base() string { return strings.TrimRight(s.cfg.BaseURL, "/") } + +var shortenTitleRe = regexp.MustCompile(`(\[[^]]*]|[({][^)}]*[)}])`) + +func (s *Source) shortenTitle(t string) string { + return shortenTitleRe.ReplaceAllString(t, "") +} + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.base()+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("akuma: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) post(ctx context.Context, rawURL string, body string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", s.base()+"/") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + if s.csrfToken != "" { + req.Header.Set("X-CSRF-TOKEN", s.csrfToken) + } + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == 419 { + s.csrfToken = "" + return s.post(ctx, rawURL, body) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("akuma: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) getCSRF() error { + if s.csrfToken != "" { + return nil + } + doc, err := s.get(context.Background(), s.base()) + if err != nil { + return err + } + token := doc.Find("meta[name*=csrf-token]").AttrOr("content", "") + if token == "" { + return fmt.Errorf("akuma: CSRF token not found") + } + s.csrfToken = token + return nil +} + +func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage { + if strings.Contains(doc.Text(), "Max keywords of 3 exceeded.") { + return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false} + } + if strings.Contains(doc.Text(), "Max keywords of 8 exceeded.") { + return source.MangasPage{Mangas: []source.SManga{}, HasNextPage: false} + } + + var mangas []source.SManga + doc.Find(".post-loop li").Each(func(_ int, el *goquery.Selection) { + link := el.Find("a").First() + href := link.AttrOr("href", "") + if href == "" { + return + } + title := strings.TrimSpace(el.Find(".overlay-title").Text()) + title = strings.ReplaceAll(title, "\"", "") + m := source.SManga{ + URL: href, + Title: s.shortenTitle(title), + } + if img := el.Find("img").First(); img.Length() > 0 { + m.ThumbnailURL = img.AttrOr("src", "") + } + if m.URL != "" && m.Title != "" { + mangas = append(mangas, m) + } + }) + + nextLink := doc.Find(".page-item a[rel*=next]").First() + nextURL := nextLink.AttrOr("href", "") + if nextURL != "" { + s.nextHash = extractCursor(nextURL) + } else { + s.nextHash = "" + } + return source.MangasPage{Mangas: mangas, HasNextPage: s.nextHash != ""} +} + +var cursorRe = regexp.MustCompile(`cursor=([^&]+)`) + +func extractCursor(url string) string { + m := cursorRe.FindStringSubmatch(url) + if m != nil { + return m[1] + } + return "" +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + if err := s.getCSRF(); err != nil { + return source.MangasPage{}, err + } + if page == 1 { + s.nextHash = "" + } + url := s.base() + if s.nextHash != "" { + url += "?cursor=" + s.nextHash + } + doc, err := s.post(context.Background(), url, "view=3") + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return source.MangasPage{}, fmt.Errorf("akuma: latest updates not supported") +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + if strings.HasPrefix(query, "https://") { + url := fmt.Sprintf("%s/g/%s", s.base(), extractID(query)) + doc, err := s.get(context.Background(), url) + if err != nil { + return source.MangasPage{}, err + } + m := s.parseMangaDetails(doc) + return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil + } + if strings.HasPrefix(query, "id:") { + id := strings.TrimPrefix(query, "id:") + doc, err := s.get(context.Background(), fmt.Sprintf("%s/g/%s", s.base(), id)) + if err != nil { + return source.MangasPage{}, err + } + m := s.parseMangaDetails(doc) + m.URL = "/g/" + id + return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil + } + if err := s.getCSRF(); err != nil { + return source.MangasPage{}, err + } + url := s.base() + if page > 1 && s.nextHash != "" { + url += "?cursor=" + s.nextHash + } + q := query + doc, err := s.post(context.Background(), url, "view=3&q="+q) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func extractID(url string) string { + parts := strings.Split(strings.TrimSuffix(url, "/"), "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return url +} + +func (s *Source) parseMangaDetails(doc *goquery.Document) source.SManga { + title := doc.Find(".entry-title").Text() + title = strings.ReplaceAll(title, "\"", "") + result := source.SManga{ + Title: s.shortenTitle(title), + } + if img := doc.Find(".img-thumbnail").First(); img.Length() > 0 { + result.ThumbnailURL = img.AttrOr("src", "") + } + var authors []string + doc.Find(".group~.value").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + authors = append(authors, t) + } + }) + result.Author = strings.Join(authors, ", ") + var genres []string + doc.Find(".male~.value").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t+" ♂") + } + }) + doc.Find(".female~.value").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t+" ♀") + } + }) + doc.Find(".other~.value").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t+" ◊") + } + }) + result.Genre = strings.Join(genres, ", ") + result.Status = source.StatusUnknown + return result +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), s.base()+manga.URL) + if err != nil { + return manga, err + } + result := s.parseMangaDetails(doc) + result.URL = manga.URL + if result.Title == "" { + result.Title = manga.Title + } + return result, nil +} + +var dateRe = regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2})`) + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), s.base()+manga.URL) + if err != nil { + return nil, err + } + m := dateRe.FindStringSubmatch(doc.Text()) + var date int64 + if m != nil { + t, err := time.Parse("2006-01-02 15:04", m[1]) + if err == nil { + date = t.UnixMilli() + } + } + return []source.SChapter{ + { + URL: manga.URL + "/1", + Name: "Chapter", + DateUpload: date, + }, + }, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + doc, err := s.get(context.Background(), s.base()+strings.TrimSuffix(chapter.URL, "/1")) + if err != nil { + return nil, err + } + total := 1 + doc.Find(".nav-select option").Each(func(_ int, el *goquery.Selection) { + if v, err := strconv.Atoi(el.AttrOr("value", "")); err == nil && v > total { + total = v + } + }) + if total == 1 { + src := doc.Find(".entry-content img").First().AttrOr("src", "") + if src != "" { + return []source.Page{{Index: 0, ImageURL: src}}, nil + } + return nil, fmt.Errorf("akuma: no images found") + } + baseURL := strings.TrimSuffix(s.base()+chapter.URL, "/1") + pages := make([]source.Page, total) + for i := 1; i <= total; i++ { + pages[i-1] = source.Page{Index: i - 1, ImageURL: fmt.Sprintf("%s/%d", baseURL, i)} + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func init() { + registry.Register(New()) +} \ No newline at end of file diff --git a/sources/all/allporncomicsco/allporncomicsco.go b/sources/all/allporncomicsco/allporncomicsco.go new file mode 100644 index 0000000..a23ea56 --- /dev/null +++ b/sources/all/allporncomicsco/allporncomicsco.go @@ -0,0 +1,21 @@ +package allporncomicsco + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "AllPornComics.co", + BaseURL: "https://allporncomics.co", + Lang: "all", + MangaSubString: "comic", + UseNewChapterEndpoint: false, + PopularMangaURLSelector: "h3 > a:not([target=_self]):last-of-type", + }) +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/asmhentai/asmhentai.go b/sources/all/asmhentai/asmhentai.go new file mode 100644 index 0000000..bbf6908 --- /dev/null +++ b/sources/all/asmhentai/asmhentai.go @@ -0,0 +1,238 @@ +// Package asmhentai implements the AsmHentai source (GalleryAdults-style). +// Multi-language: en/english, ja/japanese, zh/chinese, all (multi). +// Page list may require a POST to /inc/api.php for galleries with many pages. +package asmhentai + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const siteURL = "https://asmhentai.com" + +type Source struct { + name string + lang string + mangaLang string // e.g. "english", "japanese", "" for all + client *flare.Client + id int64 +} + +func newSource(name, lang, mangaLang string) *Source { + return &Source{ + name: name, + lang: lang, + mangaLang: mangaLang, + client: flare.NewClient(flare.WithRateLimit(1, 2)), + id: source.GenerateSourceID(name, lang), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.name } +func (s *Source) Lang() string { return s.lang } +func (s *Source) SupportsLatest() bool { return s.mangaLang != "" } + +func (s *Source) langPath() string { + if s.mangaLang != "" { + return "language/" + s.mangaLang + "/" + } + return "" +} + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + resp, err := s.client.Get(ctx, rawURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("asmhentai: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func imgAttr(img *goquery.Selection) string { + for _, attr := range []string{"data-lazy-src", "data-src", "src"} { + if v := img.AttrOr(attr, ""); v != "" && !strings.HasPrefix(v, "data:") { + return v + } + } + return "" +} + +func thumbnailToFull(u string) string { + ext := u[strings.LastIndex(u, "."):] + return strings.Replace(u, "t"+ext, ext, 1) +} + +func (s *Source) parsePage(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find(".preview_item").Each(func(_ int, el *goquery.Selection) { + href := el.Find(".image a").First().AttrOr("href", "") + if href == "" { + return + } + m := source.SManga{URL: href} + m.ThumbnailURL = imgAttr(el.Find(".image img").First()) + m.Title = strings.TrimSpace(el.Find(".caption").Text()) + if m.Title == "" { + m.Title = strings.TrimSpace(el.Find("h2, h3").First().Text()) + } + if m.URL != "" && m.Title != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find(".next.page-numbers, a[aria-label=Next]").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + u := fmt.Sprintf("%s/%spopular/?page=%d", siteURL, s.langPath(), page) + doc, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + return s.parsePage(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + u := fmt.Sprintf("%s/%s?page=%d", siteURL, s.langPath(), page) + doc, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + return s.parsePage(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + u := fmt.Sprintf("%s/search/?q=%s&page=%d", siteURL, url.QueryEscape(query), page) + doc, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + return s.parsePage(doc), nil +} + +func extractTags(info *goquery.Selection, tag string) string { + var items []string + info.Find(".tags").Each(func(_ int, tags *goquery.Selection) { + if !strings.Contains(tags.Text(), tag+":") { + return + } + tags.Find(".tag_list a").Each(func(_ int, a *goquery.Selection) { + t := strings.TrimSpace(a.Find(".tag").Text()) + if t == "" { + t = strings.TrimSpace(a.Text()) + } + if t != "" { + items = append(items, t) + } + }) + }) + return strings.Join(items, ", ") +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + rawURL := manga.URL + if !strings.HasPrefix(rawURL, "http") { + rawURL = siteURL + rawURL + } + doc, err := s.get(context.Background(), rawURL) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL, Status: source.StatusCompleted} + info := doc.Find(".book_page") + result.Title = strings.TrimSpace(info.Find("h1").Text()) + if result.Title == "" { + result.Title = manga.Title + } + result.ThumbnailURL = imgAttr(info.Find(".cover img").First()) + result.Genre = extractTags(info, "Tags") + result.Author = extractTags(info, "Artists") + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + return []source.SChapter{{URL: manga.URL, Name: "Chapter"}}, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + rawURL := chapter.URL + if !strings.HasPrefix(rawURL, "http") { + rawURL = siteURL + rawURL + } + doc, err := s.get(context.Background(), rawURL) + if err != nil { + return nil, err + } + + var pages []source.Page + doc.Find(".preview_thumb img").Each(func(_ int, img *goquery.Selection) { + if u := imgAttr(img); u != "" { + pages = append(pages, source.Page{Index: len(pages), ImageURL: thumbnailToFull(u)}) + } + }) + + // POST for remaining pages when gallery has more than the initially loaded count. + tPagesStr := doc.Find("input#t_pages").AttrOr("value", "") + tPages, _ := strconv.Atoi(tPagesStr) + if tPages > len(pages) && tPages > 0 { + loadID := doc.Find("input#load_id").AttrOr("value", "") + loadDir := doc.Find("input#load_dir").AttrOr("value", "") + csrfToken := doc.Find("meta[name=csrf-token]").AttrOr("content", "") + form := url.Values{ + "id": {loadID}, + "dir": {loadDir}, + "visible_pages": {strconv.Itoa(len(pages))}, + "t_pages": {tPagesStr}, + "type": {"2"}, + } + if csrfToken != "" { + form.Set("_token", csrfToken) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, + siteURL+"/inc/api.php", strings.NewReader(form.Encode())) + if err == nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Referer", rawURL) + resp, err := s.client.Do(req) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + if extraDoc, err := goquery.NewDocumentFromReader(resp.Body); err == nil { + extraDoc.Find(".preview_thumb img").Each(func(_ int, img *goquery.Selection) { + if u := imgAttr(img); u != "" { + pages = append(pages, source.Page{Index: len(pages), ImageURL: thumbnailToFull(u)}) + } + }) + } + } + } + } + } + + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func init() { + registry.Register(newSource("AsmHentai", "all", "")) + registry.Register(newSource("AsmHentai (English)", "en", "english")) + registry.Register(newSource("AsmHentai (Japanese)", "ja", "japanese")) + registry.Register(newSource("AsmHentai (Chinese)", "zh", "chinese")) +} diff --git a/sources/all/baobua/baobua.go b/sources/all/baobua/baobua.go new file mode 100644 index 0000000..d5b8ea5 --- /dev/null +++ b/sources/all/baobua/baobua.go @@ -0,0 +1,250 @@ +// Package baobua implements the BaoBua adult photo gallery source. +// FlareSolverr required; no full-text search; category filter; recursive page pagination. +package baobua + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const siteURL = "https://baobua.net" + +var wpComRe = regexp.MustCompile(`^https://i\d+\.wp\.com/`) + +var categoryNames = []string{ + "All", "Ao-yem", "Asia", "Beauty", "Bikini", "China", "Cosplay", + "Japan", "Nude", "Sexy", "Top", "Tattoo", "Vietnam", +} + +var categorySlugs = []string{ + "", "Ao-yem", "Asia", "beauty", "Bikini", "China", "Cosplay", + "Japan", "Nude", "Sexy", "Top", "tattoo", "Vietnam", +} + +type Source struct { + client *flare.Client + id int64 +} + +func New() *Source { + return &Source{ + client: flare.NewClient(flare.WithRateLimit(3, 1)), + id: source.GenerateSourceID("BaoBua", "all"), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return "BaoBua" } +func (s *Source) Lang() string { return "all" } +func (s *Source) SupportsLatest() bool { return false } + +func normalizeImageURL(u string) string { + if wpComRe.MatchString(u) { + u = wpComRe.ReplaceAllString(u, "https://") + u = strings.Replace(u, "?w=640", "", 1) + } + return u +} + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", siteURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("baobua: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find(".product-item").Each(func(_ int, el *goquery.Selection) { + href := el.Find("a").First().AttrOr("href", "") + if href == "" { + return + } + parsed, err := url.Parse(href) + if err != nil { + return + } + title := strings.TrimSpace(el.Find(".product-title").Text()) + if title == "" { + return + } + m := source.SManga{URL: parsed.Path, Title: title} + if src := el.Find("img.product-imgreal").First().AttrOr("src", ""); src != "" { + m.ThumbnailURL = normalizeImageURL(src) + } + mangas = append(mangas, m) + }) + hasNext := doc.Find(".pagination-custom .nextPage").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/?page=%d", siteURL, page)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return source.MangasPage{}, fmt.Errorf("baobua: latest not supported") +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + // Direct URL lookup + if strings.Contains(query, "baobua.net") { + parsed, err := url.Parse(query) + if err != nil { + return source.MangasPage{}, err + } + doc, err := s.get(context.Background(), query) + if err != nil { + return source.MangasPage{}, err + } + if doc.Find(".product-item").Length() == 0 && doc.Find(".article-body").Length() > 0 { + m := s.parseMangaDetailsDoc(doc) + m.URL = parsed.Path + return source.MangasPage{Mangas: []source.SManga{m}, HasNextPage: false}, nil + } + return s.parseMangas(doc), nil + } + + // Category filter + for _, f := range filters { + if sf, ok := f.(*source.SelectFilter); ok && sf.FilterName == "Category" { + idx := sf.Selected + if idx > 0 && idx < len(categorySlugs) { + u := fmt.Sprintf("%s/category/%s/?page=%d", siteURL, categorySlugs[idx], page) + doc, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil + } + } + } + + if strings.TrimSpace(query) != "" { + return source.MangasPage{}, fmt.Errorf("baobua: full-text search is not supported") + } + return s.GetPopularManga(page) +} + +func (s *Source) parseMangaDetailsDoc(doc *goquery.Document) source.SManga { + m := source.SManga{Status: source.StatusCompleted} + m.Title = strings.TrimSpace( + doc.Find(".product-title, h1, .article-title, .post-title").First().Text(), + ) + if src := doc.Find("img.product-imgreal, .article-body img").First().AttrOr("src", ""); src != "" { + m.ThumbnailURL = normalizeImageURL(src) + } + var genres []string + doc.Find(".article-tags a").Each(func(_ int, a *goquery.Selection) { + if t := strings.TrimSpace(a.Text()); t != "" { + genres = append(genres, t) + } + }) + m.Genre = strings.Join(genres, ", ") + return m +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), siteURL+manga.URL) + if err != nil { + return manga, err + } + result := s.parseMangaDetailsDoc(doc) + result.URL = manga.URL + if result.Title == "" { + result.Title = manga.Title + } + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), siteURL+manga.URL) + if err != nil { + return nil, err + } + chapterURL := manga.URL + if canonical := doc.Find("link[rel=canonical]").First(); canonical.Length() > 0 { + if href := canonical.AttrOr("href", ""); href != "" { + if parsed, err := url.Parse(href); err == nil { + chapterURL = parsed.Path + } + } + } + var date int64 + if dateStr := strings.TrimSpace(doc.Find(".article-date-comment .date").Text()); dateStr != "" { + // "Mon Jan 02 2006" or "Mon Jan 2 2006" + for _, layout := range []string{"Mon Jan 02 2006", "Mon Jan 2 2006"} { + if t, err := time.Parse(layout, dateStr); err == nil { + date = t.UnixMilli() + break + } + } + } + return []source.SChapter{{URL: chapterURL, Name: "Gallery", DateUpload: date}}, nil +} + +func (s *Source) recursivePages(rawURL string, offset int) ([]source.Page, error) { + doc, err := s.get(context.Background(), rawURL) + if err != nil { + return nil, err + } + var pages []source.Page + doc.Find(".article-body img").Each(func(_ int, img *goquery.Selection) { + src := img.AttrOr("src", "") + if src == "" { + src = img.AttrOr("data-src", "") + } + if src != "" { + pages = append(pages, source.Page{Index: offset + len(pages), ImageURL: normalizeImageURL(src)}) + } + }) + nextURL := doc.Find("a.page-numbers:contains(Next)").First().AttrOr("href", "") + if nextURL != "" { + extra, err := s.recursivePages(nextURL, offset+len(pages)) + if err == nil { + pages = append(pages, extra...) + } + } + return pages, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + return s.recursivePages(siteURL+chapter.URL, 0) +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } + +func (s *Source) GetFilterList() []source.Filter { + return []source.Filter{ + &source.SelectFilter{FilterName: "Category", Values: categoryNames}, + } +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/beauty3600000/beauty3600000.go b/sources/all/beauty3600000/beauty3600000.go new file mode 100644 index 0000000..674d06a --- /dev/null +++ b/sources/all/beauty3600000/beauty3600000.go @@ -0,0 +1,319 @@ +// Package beauty3600000 implements the "3600000 Beauty" source. +// Uses WordPress REST API (wp-json/wp/v2); FlareSolverr required. +// Manga URL = post ID (string). Thumbnail extracted from post content HTML. +package beauty3600000 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const ( + siteURL = "https://3600000.xyz" + apiBase = "wp-json/wp/v2" + perPage = 100 +) + +type postDTO struct { + ID int `json:"id"` + Link string `json:"link"` + Title struct { + Rendered string `json:"rendered"` + } `json:"title"` + Content struct { + Rendered string `json:"rendered"` + } `json:"content"` + Date string `json:"date"` +} + +type termDTO struct { + ID int `json:"id"` + Name string `json:"name"` +} + +var dateFormat = func() *time.Location { + loc, _ := time.LoadLocation("UTC") + return loc +}() + +func parseDate(s string) int64 { + t, err := time.ParseInLocation("2006-01-02T15:04:05", s, dateFormat) + if err != nil { + return 0 + } + return t.UnixMilli() +} + +// Category IDs from the Kotlin source. +var categoryNames = []string{ + "Any", "Aidol", "China", "Chinese", "Cosplay", "Gravure", + "Japan", "Korea", "Magazine", "Photobook", "Thailand", + "Uncategorized", "Western", +} +var categoryIDs = []string{ + "", "6", "3293", "5", "4", "7", "3291", "2128", "9", "10", "8", "1", "11", +} + +type Source struct { + client *flare.Client + id int64 +} + +func New() *Source { + return &Source{ + client: flare.NewClient(flare.WithRateLimit(1, 1)), + id: source.GenerateSourceID("3600000 Beauty", "all"), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return "3600000 Beauty" } +func (s *Source) Lang() string { return "all" } +func (s *Source) SupportsLatest() bool { return false } + +func (s *Source) apiURL(path string, params url.Values) string { + u := siteURL + "/" + apiBase + "/" + path + if len(params) > 0 { + u += "?" + params.Encode() + } + return u +} + +func (s *Source) getJSON(ctx context.Context, rawURL string) ([]byte, http.Header, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, nil, err + } + req.Header.Set("Referer", siteURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("beauty3600000: HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + return body, resp.Header, err +} + +func postToManga(p postDTO) source.SManga { + title := p.Title.Rendered + m := source.SManga{ + URL: strconv.Itoa(p.ID), + Title: title, + Status: source.StatusCompleted, + } + // Thumbnail from first img in content HTML. + doc, err := goquery.NewDocumentFromReader(strings.NewReader(p.Content.Rendered)) + if err == nil { + m.ThumbnailURL = doc.Find("img").First().AttrOr("src", "") + } + return m +} + +func (s *Source) parsePosts(body []byte, headers http.Header, currentPage int) (source.MangasPage, error) { + var posts []postDTO + if err := json.Unmarshal(body, &posts); err != nil { + return source.MangasPage{}, fmt.Errorf("beauty3600000: %w", err) + } + mangas := make([]source.SManga, 0, len(posts)) + for _, p := range posts { + mangas = append(mangas, postToManga(p)) + } + totalPages, _ := strconv.Atoi(headers.Get("X-WP-TotalPages")) + return source.MangasPage{Mangas: mangas, HasNextPage: currentPage < totalPages}, nil +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + params := url.Values{ + "page": {strconv.Itoa(page)}, + "per_page": {strconv.Itoa(perPage)}, + } + body, headers, err := s.getJSON(context.Background(), s.apiURL("posts", params)) + if err != nil { + return source.MangasPage{}, err + } + return s.parsePosts(body, headers, page) +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return source.MangasPage{}, fmt.Errorf("beauty3600000: latest not supported") +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + // URL-based lookup: extract post ID or slug. + if parsed, err := url.Parse(query); err == nil && parsed.Host != "" { + params := url.Values{} + if id := parsed.Query().Get("p"); id != "" { + params.Set("include", id) + } else if segments := strings.Split(strings.Trim(parsed.Path, "/"), "/"); len(segments) > 0 { + slug := segments[len(segments)-1] + slug = strings.TrimSuffix(slug, ".html") + if slug != "" { + params.Set("slug", slug) + } + } + if len(params) > 0 { + body, headers, err := s.getJSON(context.Background(), s.apiURL("posts", params)) + if err != nil { + return source.MangasPage{}, err + } + return s.parsePosts(body, headers, 1) + } + } + + params := url.Values{ + "page": {strconv.Itoa(page)}, + "per_page": {strconv.Itoa(perPage)}, + } + // Apply filters. + for _, f := range filters { + if sf, ok := f.(*source.SelectFilter); ok && sf.FilterName == "Category" { + if sf.Selected > 0 && sf.Selected < len(categoryIDs) { + params.Set("categories", categoryIDs[sf.Selected]) + goto doSearch + } + } + } + if query != "" { + params.Set("search", query) + } + +doSearch: + body, headers, err := s.getJSON(context.Background(), s.apiURL("posts", params)) + if err != nil { + return source.MangasPage{}, err + } + return s.parsePosts(body, headers, page) +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + params := url.Values{} + if _, err := strconv.Atoi(manga.URL); err == nil { + // Numeric ID — direct path lookup. + body, _, err := s.getJSON(context.Background(), s.apiURL("posts/"+manga.URL, nil)) + if err != nil { + return manga, err + } + var p postDTO + if err := json.Unmarshal(body, &p); err != nil { + return manga, err + } + result := postToManga(p) + result.Genre = s.fetchGenre(p.ID) + result.URL = manga.URL + return result, nil + } + // Slug-based. + params.Set("slug", strings.Trim(manga.URL, "/")) + body, _, err := s.getJSON(context.Background(), s.apiURL("posts", params)) + if err != nil { + return manga, err + } + var posts []postDTO + if err := json.Unmarshal(body, &posts); err != nil || len(posts) == 0 { + return manga, err + } + result := postToManga(posts[0]) + result.Genre = s.fetchGenre(posts[0].ID) + result.URL = manga.URL + return result, nil +} + +func (s *Source) fetchGenre(postID int) string { + var terms []string + for _, termType := range []string{"categories", "tags"} { + params := url.Values{"post": {strconv.Itoa(postID)}} + body, _, err := s.getJSON(context.Background(), s.apiURL(termType, params)) + if err != nil { + continue + } + var list []termDTO + if json.Unmarshal(body, &list) != nil { + continue + } + for _, t := range list { + if t.Name != "" { + terms = append(terms, t.Name) + } + } + } + return strings.Join(terms, ", ") +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + var body []byte + var err error + if _, parseErr := strconv.Atoi(manga.URL); parseErr == nil { + body, _, err = s.getJSON(context.Background(), s.apiURL("posts/"+manga.URL, nil)) + } else { + params := url.Values{"slug": {strings.Trim(manga.URL, "/")}} + body, _, err = s.getJSON(context.Background(), s.apiURL("posts", params)) + if err == nil { + var posts []postDTO + if json.Unmarshal(body, &posts) == nil && len(posts) > 0 { + body, _ = json.Marshal(posts[0]) + } + } + } + if err != nil { + return nil, err + } + var p postDTO + if err := json.Unmarshal(body, &p); err != nil { + return nil, err + } + return []source.SChapter{{ + URL: strconv.Itoa(p.ID), + Name: "Gallery", + DateUpload: parseDate(p.Date), + }}, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + body, _, err := s.getJSON(context.Background(), s.apiURL("posts/"+chapter.URL, nil)) + if err != nil { + return nil, err + } + var p postDTO + if err := json.Unmarshal(body, &p); err != nil { + return nil, err + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(p.Content.Rendered)) + if err != nil { + return nil, err + } + var pages []source.Page + doc.Find("img").Each(func(i int, img *goquery.Selection) { + if src := img.AttrOr("src", ""); src != "" { + pages = append(pages, source.Page{Index: i, ImageURL: src}) + } + }) + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } + +func (s *Source) GetFilterList() []source.Filter { + return []source.Filter{ + &source.SelectFilter{FilterName: "Category", Values: categoryNames}, + } +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/buondua/buondua.go b/sources/all/buondua/buondua.go new file mode 100644 index 0000000..e41f11b --- /dev/null +++ b/sources/all/buondua/buondua.go @@ -0,0 +1,249 @@ +// Package buondua implements the BuonDua photo gallery source. +// FlareSolverr required; multi-page galleries split into one chapter per page. +// Popular: /hot; Latest: /; Search: /?search= or /tag/{id}. +package buondua + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const siteURL = "https://buondua.com" + +// date format used in .article-info > small +var dateFormat = "15:04 02-01-2006" + +type Source struct { + client *flare.Client + id int64 +} + +func New() *Source { + return &Source{ + client: flare.NewClient(flare.WithRateLimit(10, 1)), + id: source.GenerateSourceID("Buon Dua", "all"), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return "Buon Dua" } +func (s *Source) Lang() string { return "all" } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", siteURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("buondua: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) offset(page int) int { return 20 * (page - 1) } + +func (s *Source) parseMangas(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find(".blog > div").Each(func(_ int, el *goquery.Selection) { + link := el.Find(".item-content .item-link").First() + href := link.AttrOr("href", "") + if href == "" { + return + } + parsed, err := url.Parse(href) + if err != nil { + return + } + m := source.SManga{ + URL: parsed.Path, + Title: strings.TrimSpace(link.Text()), + } + if img := el.Find("img").First(); img.Length() > 0 { + m.ThumbnailURL = img.AttrOr("src", "") + } + if m.URL != "" && m.Title != "" { + mangas = append(mangas, m) + } + }) + hasNext := doc.Find(".pagination-next:not([disabled])").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/hot?start=%d", siteURL, s.offset(page))) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/?start=%d", siteURL, s.offset(page))) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + var rawURL string + if query != "" { + rawURL = fmt.Sprintf("%s/?search=%s&start=%d", siteURL, url.QueryEscape(query), s.offset(page)) + } else { + // Tag text filter. + for _, f := range filters { + if tf, ok := f.(*source.TextFilter); ok && tf.FilterName == "Tag ID" && tf.Text != "" { + rawURL = fmt.Sprintf("%s/tag/%s&start=%d", siteURL, url.PathEscape(tf.Text), s.offset(page)) + break + } + } + } + if rawURL == "" { + return s.GetPopularManga(page) + } + doc, err := s.get(context.Background(), rawURL) + if err != nil { + return source.MangasPage{}, err + } + return s.parseMangas(doc), nil +} + +func cleanTitle(t string) string { + // Strip " - ( Page N / M )" suffix + if i := strings.Index(t, " - ( Page "); i != -1 { + return strings.TrimSpace(t[:i]) + } + return strings.TrimSpace(t) +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), siteURL+manga.URL) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL, Status: source.StatusUnknown} + if h := doc.Find(".article-header").First(); h.Length() > 0 { + result.Title = cleanTitle(h.Text()) + } + if result.Title == "" { + result.Title = manga.Title + } + // Build description from article info + download links + password. + var descParts []string + if info := strings.TrimSpace(strings.ReplaceAll(doc.Find(".article-info > strong").Text(), "Buondua", "")); info != "" { + descParts = append(descParts, info) + } + var dlLinks []string + doc.Find(".article-links a[href]").Each(func(_ int, a *goquery.Selection) { + href := a.AttrOr("href", "") + text := strings.TrimSpace(a.Text()) + if href != "" && text != "" { + dlLinks = append(dlLinks, fmt.Sprintf("[%s](%s)", text, href)) + } + }) + if len(dlLinks) > 0 { + descParts = append(descParts, strings.Join(dlLinks, "\n")) + } + if pw := strings.TrimSpace(doc.Find("code").Text()); pw != "" { + descParts = append(descParts, pw) + } + result.Description = strings.Join(descParts, "\n\n") + var genres []string + doc.Find(".article-tags .tags > .tag").Each(func(_ int, el *goquery.Selection) { + t := strings.TrimPrefix(strings.TrimSpace(el.Text()), "#") + if t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + return result, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), siteURL+manga.URL) + if err != nil { + return nil, err + } + + // Date from .article-info > small + var date int64 + if dateStr := strings.TrimSpace(doc.Find(".article-info > small").First().Text()); dateStr != "" { + if t, err := time.Parse(dateFormat, dateStr); err == nil { + date = t.UnixMilli() + } + } + + // Max page from last pagination-next link's "page" query param. + maxPage := 1 + doc.Find("nav.pagination:first-of-type a.pagination-next").Each(func(_ int, a *goquery.Selection) { + href := a.AttrOr("href", "") + if parsed, err := url.Parse(href); err == nil { + if p, err := strconv.Atoi(parsed.Query().Get("page")); err == nil && p > maxPage { + maxPage = p + } + } + }) + + baseURL := siteURL + manga.URL + chapters := make([]source.SChapter, maxPage) + for i := maxPage; i >= 1; i-- { + chURL := baseURL + if i > 1 { + chURL = fmt.Sprintf("%s?page=%d", baseURL, i) + } + chapters[maxPage-i] = source.SChapter{ + URL: strings.TrimPrefix(chURL, siteURL), + Name: fmt.Sprintf("Page %d", i), + DateUpload: date, + } + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + doc, err := s.get(context.Background(), siteURL+chapter.URL) + if err != nil { + return nil, err + } + var pages []source.Page + doc.Find(".article-fulltext img").Each(func(i int, img *goquery.Selection) { + src := img.AttrOr("src", "") + if src == "" { + src = img.AttrOr("data-src", "") + } + if src != "" { + pages = append(pages, source.Page{Index: i, ImageURL: src}) + } + }) + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } + +func (s *Source) GetFilterList() []source.Filter { + return []source.Filter{ + &source.TextFilter{FilterName: "Tag ID"}, + } +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/comicfury/comicfury.go b/sources/all/comicfury/comicfury.go new file mode 100644 index 0000000..56d3617 --- /dev/null +++ b/sources/all/comicfury/comicfury.go @@ -0,0 +1,366 @@ +// Package comicfury implements the Comic Fury webcomic hosting source. +// Multi-language factory. Search-based popular (sort=popularity) and latest (sort=lastupdate). +// Chapter list scraped from /read/{comicUrl}/archive; supports hierarchical chapters-in-chapters. +// FlareSolverr used (matches Kotlin cloudflareClient). +package comicfury + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const siteURL = "https://comicfury.com" + +type Source struct { + name string + lang string + siteLang string // used in search query + client *flare.Client + id int64 +} + +func newSource(name, lang, siteLang string) *Source { + return &Source{ + name: name, + lang: lang, + siteLang: siteLang, + client: flare.NewClient(flare.WithRateLimit(1, 2)), + id: source.GenerateSourceID(name, lang), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.name } +func (s *Source) Lang() string { return s.lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", siteURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("comicfury: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) searchURL(page int, query, sort string, filters []source.Filter) string { + params := url.Values{ + "query": {query}, + "page": {strconv.Itoa(page)}, + "language": {s.siteLang}, + "sort": {sort}, + } + for _, f := range filters { + switch sf := f.(type) { + case *source.TextFilter: + if sf.FilterName == "Tags" && sf.Text != "" { + params.Set("tags", sf.Text) + } + case *source.SelectFilter: + switch sf.FilterName { + case "Sort By": + params.Set("sort", strconv.Itoa(sf.Selected)) + case "Last Updated": + params.Set("lastupdate", strconv.Itoa(sf.Selected)) + case "Violence": + params.Set("fv", strconv.Itoa(sf.Selected)) + case "Frontal Nudity": + params.Set("fn", strconv.Itoa(sf.Selected)) + case "Strong Language": + params.Set("fl", strconv.Itoa(sf.Selected)) + case "Sexual Content": + params.Set("fs", strconv.Itoa(sf.Selected)) + } + case *source.CheckboxFilter: + if sf.FilterName == "Comic Completed" { + completed := 1 + if sf.State { + completed = 0 + } + params.Set("completed", strconv.Itoa(completed)) + } + } + } + return siteURL + "/search.php?" + params.Encode() +} + +func (s *Source) parseSearch(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find("div.webcomic-result").Each(func(_ int, el *goquery.Selection) { + link := el.Find("div.webcomic-result-avatar a").First() + href := link.AttrOr("href", "") + title := el.Find("div.webcomic-result-title").First().AttrOr("title", "") + thumb := el.Find("div.webcomic-result-avatar a img").First().AttrOr("src", "") + if href == "" || title == "" { + return + } + mangas = append(mangas, source.SManga{URL: href, Title: title, ThumbnailURL: thumb}) + }) + hasNext := doc.Find("div.search-next-page").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), s.searchURL(page, "", "1", nil)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseSearch(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), s.searchURL(page, "", "2", nil)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseSearch(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + doc, err := s.get(context.Background(), s.searchURL(page, query, "0", filters)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseSearch(doc), nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + rawURL := manga.URL + if !strings.HasPrefix(rawURL, "http") { + rawURL = siteURL + rawURL + } + doc, err := s.get(context.Background(), rawURL) + if err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + desDiv := doc.Find("div.description-tags") + result.Description = strings.TrimSpace(desDiv.Parent().Clone().Find("*").Remove().End().Text()) + var genres []string + desDiv.Children().Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + genres = append(genres, t) + } + }) + result.Genre = strings.Join(genres, ", ") + var authors []string + doc.Find("a.authorname").Each(func(_ int, el *goquery.Selection) { + if t := strings.TrimSpace(el.Text()); t != "" { + authors = append(authors, t) + } + }) + result.Author = strings.Join(authors, ", ") + if result.Title == "" { + result.Title = manga.Title + } + return result, nil +} + +// comicURL extracts the comic slug from a manga URL like /comicprofile.php?url=slug. +func comicURL(mangaURL string) string { + parsed, err := url.Parse(mangaURL) + if err != nil { + return mangaURL + } + if u := parsed.Query().Get("url"); u != "" { + return u + } + // Fallback: use last path segment. + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(segments) > 0 { + return segments[len(segments)-1] + } + return mangaURL +} + +// parseDate handles formats like "4th March 2023 12:00 PM", "4 March 2023", "March 4 2023". +var dateOrdinalRe = regexp.MustCompile(`(?i)(\d+)(st|nd|rd|th)`) + +func parseDate(s string) int64 { + s = dateOrdinalRe.ReplaceAllString(s, "$1") + s = strings.ReplaceAll(s, ",", "") + s = strings.TrimSpace(s) + formats := []string{ + "2 January 2006 3:04 PM", + "2 January 2006", + "January 2 2006", + } + for _, f := range formats { + if t, err := time.Parse(f, s); err == nil { + return t.UnixMilli() + } + } + return 0 +} + +// collectChapters follows pagination from a given archive page. +func (s *Source) collectChapters(startDoc *goquery.Document) ([]source.SChapter, error) { + var chapters []source.SChapter + doc := startDoc + for { + doc.Find("a:has(div.archive-comic)").Each(func(_ int, a *goquery.Selection) { + href := a.AttrOr("href", "") + if href == "" { + return + } + parsed, _ := url.Parse(href) + chURL := parsed.Path + if parsed.RawQuery != "" { + chURL += "?" + parsed.RawQuery + } + name := strings.TrimSpace(a.Find(".archive-comic-title").Text()) + if name == "" { + name = "Chapter" + } + date := parseDate(strings.TrimSpace(a.Find(".archive-comic-date").Text())) + chapters = append(chapters, source.SChapter{URL: chURL, Name: name, DateUpload: date}) + }) + nextPage := doc.Find("span.vfpagecurrent + a.vfpage").First() + nextHref := nextPage.AttrOr("href", "") + if nextHref == "" { + break + } + nextURL := nextHref + if !strings.HasPrefix(nextURL, "http") { + nextURL = siteURL + nextURL + } + next, err := s.get(context.Background(), nextURL) + if err != nil { + break + } + doc = next + } + return chapters, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + slug := comicURL(manga.URL) + archiveURL := fmt.Sprintf("%s/read/%s/archive", siteURL, slug) + doc, err := s.get(context.Background(), archiveURL) + if err != nil { + return nil, err + } + + var chapters []source.SChapter + archiveLinks := doc.Find("a:has(div.archive-chapter)") + if archiveLinks.Length() > 0 { + // Has parent chapters — fetch each sub-archive. + var fetchErr error + archiveLinks.Each(func(_ int, a *goquery.Selection) { + if fetchErr != nil { + return + } + href := a.AttrOr("href", "") + if href == "" { + return + } + if !strings.HasPrefix(href, "http") { + href = siteURL + href + } + subDoc, err := s.get(context.Background(), href) + if err != nil { + fetchErr = err + return + } + sub, err := s.collectChapters(subDoc) + if err != nil { + fetchErr = err + return + } + chapters = append(chapters, sub...) + }) + if fetchErr != nil { + return nil, fetchErr + } + } else { + chapters, err = s.collectChapters(doc) + if err != nil { + return nil, err + } + } + + // Number and reverse (oldest first → chapter_number ascending). + for i := range chapters { + chapters[i].ChapterNumber = float32(i) + } + // Reverse so newest is first in list. + for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 { + chapters[i], chapters[j] = chapters[j], chapters[i] + } + return chapters, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + rawURL := chapter.URL + if !strings.HasPrefix(rawURL, "http") { + rawURL = siteURL + rawURL + } + doc, err := s.get(context.Background(), rawURL) + if err != nil { + return nil, err + } + var pages []source.Page + doc.Find("div.is--comic-page div.is--image-segment div img").Each(func(i int, img *goquery.Selection) { + if src := img.AttrOr("src", ""); src != "" { + pages = append(pages, source.Page{Index: i, URL: rawURL, ImageURL: src}) + } + }) + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } + +func (s *Source) GetFilterList() []source.Filter { + return []source.Filter{ + &source.TextFilter{FilterName: "Tags"}, + &source.SelectFilter{FilterName: "Sort By", Values: []string{"Relevance", "Popularity", "Last Update"}}, + &source.SelectFilter{FilterName: "Last Updated", Values: []string{"All Time", "This Week", "This Month", "This Year", "Completed Only"}}, + &source.CheckboxFilter{FilterName: "Comic Completed"}, + &source.SelectFilter{FilterName: "Violence", Values: []string{"None / Minimal", "Violent Content", "Gore / Graphic"}, Selected: 2}, + &source.SelectFilter{FilterName: "Frontal Nudity", Values: []string{"None", "Occasional", "Frequent"}, Selected: 2}, + &source.SelectFilter{FilterName: "Strong Language", Values: []string{"None", "Occasional", "Frequent"}, Selected: 2}, + &source.SelectFilter{FilterName: "Sexual Content", Values: []string{"No Sexual Content", "Sexual Situations", "Strong Sexual Themes"}, Selected: 2}, + } +} + +func init() { + instances := []struct{ name, lang, siteLang string }{ + {"Comic Fury", "all", "all"}, + {"Comic Fury", "en", "en"}, + {"Comic Fury", "es", "es"}, + {"Comic Fury", "pt-BR", "pt"}, + {"Comic Fury", "de", "de"}, + {"Comic Fury", "fr", "fr"}, + {"Comic Fury", "it", "it"}, + {"Comic Fury", "pl", "pl"}, + {"Comic Fury", "ja", "ja"}, + {"Comic Fury", "zh", "zh"}, + {"Comic Fury", "ru", "ru"}, + {"Comic Fury", "fi", "fi"}, + {"Comic Fury", "other", "other"}, + {"Comic Fury (No Text)", "other", "notext"}, + } + for _, inst := range instances { + registry.Register(newSource(inst.name, inst.lang, inst.siteLang)) + } +} diff --git a/sources/all/comicgrowl/comicgrowl.go b/sources/all/comicgrowl/comicgrowl.go new file mode 100644 index 0000000..c9b1fd0 --- /dev/null +++ b/sources/all/comicgrowl/comicgrowl.go @@ -0,0 +1,18 @@ +package comicgrowl + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/comiciviewer" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Comic Growl", + BaseURL: "https://comic-growl.com", + Lang: "all", + }) +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/comicklive/comicklive.go b/sources/all/comicklive/comicklive.go new file mode 100644 index 0000000..4dc44b0 --- /dev/null +++ b/sources/all/comicklive/comicklive.go @@ -0,0 +1,426 @@ +// Package comicklive implements the Comick (Unoriginal) source (comick.live / comick.art). +// Multi-language. Popular via /api/comics/top (6 virtual pages); latest via /api/chapters/latest. +// Search is cursor-based (/api/search). Details and pages scraped from HTML (#comic-data / #sv-data). +package comicklive + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const baseURL = "https://comick.live" + +// DTO types + +type browseComic struct { + Thumbnail string `json:"default_thumbnail"` + Slug string `json:"slug"` + Title string `json:"title"` +} + +func (b browseComic) toSManga() source.SManga { + return source.SManga{URL: b.Slug, Title: b.Title, ThumbnailURL: b.Thumbnail} +} + +type dataList struct { + Data []browseComic `json:"data"` +} + +type searchResp struct { + Data []browseComic `json:"data"` + NextCursor string `json:"next_cursor"` +} + +type comicDTO struct { + Title string `json:"title"` + Slug string `json:"slug"` + Thumbnail string `json:"default_thumbnail"` + Status int `json:"status"` + TranslationCompleted bool `json:"translation_completed"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Authors []struct { + Name string `json:"name"` + } `json:"authors"` + Desc string `json:"desc"` + ContentRating string `json:"content_rating"` + Country string `json:"country"` + Genres []struct { + Genre struct { + Name string `json:"name"` + } `json:"md_genres"` + } `json:"md_comic_md_genres"` + Titles []struct { + Title string `json:"title"` + } `json:"md_titles"` +} + +type chapterListResp struct { + Data []chapterDTO `json:"data"` + Pagination struct { + Page int `json:"current_page"` + LastPage int `json:"last_page"` + } `json:"pagination"` +} + +type chapterDTO struct { + HID string `json:"hid"` + Chap string `json:"chap"` + Vol string `json:"vol"` + Lang string `json:"lang"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + Groups []string `json:"group_name"` +} + +type pageListDTO struct { + Chapter struct { + Images []struct { + URL string `json:"url"` + } `json:"images"` + } `json:"chapter"` +} + +// Source + +type Source struct { + lang string + siteLang string + client *flare.Client + id int64 + mu sync.Mutex + cursor string +} + +func newSource(lang, siteLang string) *Source { + name := "Comick (Unoriginal)" + return &Source{ + lang: lang, + siteLang: siteLang, + client: flare.NewClient(flare.WithRateLimit(1, 2)), + id: source.GenerateSourceID(name, lang), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return "Comick (Unoriginal)" } +func (s *Source) Lang() string { return s.lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) get(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", baseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("comicklive: HTTP %d for %s", resp.StatusCode, rawURL) + } + return io.ReadAll(resp.Body) +} + +func (s *Source) getDoc(ctx context.Context, rawURL string) (*goquery.Document, error) { + body, err := s.get(ctx, rawURL) + if err != nil { + return nil, err + } + return goquery.NewDocumentFromReader(strings.NewReader(string(body))) +} + +// Popular uses 6 virtual pages cycling through top-comics queries. +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + if page < 1 || page > 6 { + return source.MangasPage{}, nil + } + days := []int{7, 30, 90}[(page-1)%3] + topType := "follow" + if page > 3 { + topType = "most_follow_new" + } + u := fmt.Sprintf("%s/api/comics/top?days=%d&type=%s", baseURL, days, topType) + body, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + var resp dataList + if err := json.Unmarshal(body, &resp); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, 0, len(resp.Data)) + for _, c := range resp.Data { + mangas = append(mangas, c.toSManga()) + } + return source.MangasPage{Mangas: mangas, HasNextPage: page < 6}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + u := fmt.Sprintf("%s/api/chapters/latest?order=new&page=%d", baseURL, page) + body, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + var resp dataList + if err := json.Unmarshal(body, &resp); err != nil { + return source.MangasPage{}, err + } + mangas := make([]source.SManga, 0, len(resp.Data)) + for _, c := range resp.Data { + mangas = append(mangas, c.toSManga()) + } + return source.MangasPage{Mangas: mangas, HasNextPage: len(resp.Data) == 100}, nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + if page == 1 { + s.mu.Lock() + s.cursor = "" + s.mu.Unlock() + } + + params := url.Values{ + "type": {"comic"}, + "showAll": {"false"}, + "exclude_mylist": {"false"}, + "order_by": {"created_at"}, + "order_direction": {"desc"}, + } + if query != "" { + if len(strings.TrimSpace(query)) < 3 { + return source.MangasPage{}, fmt.Errorf("comicklive: query must be at least 3 characters") + } + params.Set("q", strings.TrimSpace(query)) + } + s.mu.Lock() + cur := s.cursor + s.mu.Unlock() + if page > 1 && cur != "" { + params.Set("cursor", cur) + } + + u := baseURL + "/api/search?" + params.Encode() + body, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + var resp searchResp + if err := json.Unmarshal(body, &resp); err != nil { + return source.MangasPage{}, err + } + s.mu.Lock() + s.cursor = resp.NextCursor + s.mu.Unlock() + + mangas := make([]source.SManga, 0, len(resp.Data)) + for _, c := range resp.Data { + mangas = append(mangas, c.toSManga()) + } + return source.MangasPage{Mangas: mangas, HasNextPage: resp.NextCursor != ""}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.getDoc(context.Background(), baseURL+"/comic/"+manga.URL) + if err != nil { + return manga, err + } + raw := doc.Find("#comic-data").Text() + if raw == "" { + return manga, fmt.Errorf("comicklive: #comic-data not found") + } + var data comicDTO + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return manga, err + } + + result := source.SManga{URL: manga.URL} + result.Title = data.Title + result.ThumbnailURL = data.Thumbnail + result.Status = comickStatus(data.Status, data.TranslationCompleted) + + var authors []string + for _, a := range data.Authors { + authors = append(authors, a.Name) + } + result.Author = strings.Join(authors, ", ") + + var artists []string + for _, a := range data.Artists { + artists = append(artists, a.Name) + } + result.Artist = strings.Join(artists, ", ") + + // Description: strip HTML tags. + descDoc, _ := goquery.NewDocumentFromReader(strings.NewReader(data.Desc)) + desc := strings.TrimSpace(descDoc.Text()) + if len(data.Titles) > 0 { + var alt []string + for _, t := range data.Titles { + if t.Title != "" { + alt = append(alt, "- "+t.Title) + } + } + if len(alt) > 0 { + desc += "\n\nAlternative Titles:\n" + strings.Join(alt, "\n") + } + } + result.Description = desc + + var genres []string + switch data.Country { + case "jp": + genres = append(genres, "Manga") + case "cn": + genres = append(genres, "Manhua") + case "ko": + genres = append(genres, "Manhwa") + } + switch data.ContentRating { + case "suggestive": + genres = append(genres, "Content Rating: Suggestive") + case "erotica": + genres = append(genres, "Content Rating: Erotica") + } + for _, g := range data.Genres { + if g.Genre.Name != "" { + genres = append(genres, g.Genre.Name) + } + } + result.Genre = strings.Join(genres, ", ") + + return result, nil +} + +func comickStatus(status int, translationCompleted bool) int { + switch status { + case 1: + return source.StatusOngoing + case 2: + if translationCompleted { + return source.StatusCompleted + } + return source.StatusOngoing + case 3: + return source.StatusCancelled + case 4: + return source.StatusHiatus + } + return source.StatusUnknown +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + var chapters []chapterDTO + page := 1 + for { + u := fmt.Sprintf("%s/api/comics/%s/chapter-list?lang=%s&page=%d", baseURL, manga.URL, s.siteLang, page) + body, err := s.get(context.Background(), u) + if err != nil { + return nil, err + } + var resp chapterListResp + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + chapters = append(chapters, resp.Data...) + if resp.Pagination.Page >= resp.Pagination.LastPage { + break + } + page++ + } + + result := make([]source.SChapter, 0, len(chapters)) + for _, ch := range chapters { + chURL := fmt.Sprintf("/comic/%s/%s-chapter-%s-%s", manga.URL, ch.HID, ch.Chap, ch.Lang) + name := buildChapterName(ch) + result = append(result, source.SChapter{ + URL: chURL, + Name: name, + DateUpload: parseComickDate(ch.CreatedAt), + Scanlator: strings.Join(ch.Groups, ", "), + }) + } + return result, nil +} + +func buildChapterName(ch chapterDTO) string { + var b strings.Builder + if ch.Vol != "" { + b.WriteString("Vol. ") + b.WriteString(ch.Vol) + b.WriteString(" ") + } + b.WriteString("Ch. ") + b.WriteString(ch.Chap) + if ch.Title != "" { + b.WriteString(": ") + b.WriteString(ch.Title) + } + return b.String() +} + +func parseComickDate(s string) int64 { + // "2024-01-15T10:30:00.123456Z" — try RFC3339Nano then RFC3339. + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000000Z"} { + if t, err := time.Parse(layout, s); err == nil { + return t.UnixMilli() + } + } + return 0 +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + rawURL := chapter.URL + if !strings.HasPrefix(rawURL, "http") { + rawURL = baseURL + rawURL + } + doc, err := s.getDoc(context.Background(), rawURL) + if err != nil { + return nil, err + } + raw := doc.Find("#sv-data").Text() + if raw == "" { + return nil, fmt.Errorf("comicklive: #sv-data not found") + } + var data pageListDTO + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return nil, err + } + pages := make([]source.Page, 0, len(data.Chapter.Images)) + for i, img := range data.Chapter.Images { + pages = append(pages, source.Page{Index: i, ImageURL: img.URL}) + } + return pages, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func init() { + langs := []struct{ lang, site string }{ + {"en", "en"}, {"ru", "ru"}, {"vi", "vi"}, {"fr", "fr"}, + {"pl", "pl"}, {"id", "id"}, {"tr", "tr"}, {"it", "it"}, + {"es", "es"}, {"uk", "uk"}, {"de", "de"}, {"ko", "ko"}, + {"th", "th"}, {"ro", "ro"}, {"ms", "ms"}, {"ja", "ja"}, + {"sv", "sv"}, {"no", "no"}, + } + for _, l := range langs { + registry.Register(newSource(l.lang, l.site)) + } +} diff --git a/sources/all/comicsvalley/comicsvalley.go b/sources/all/comicsvalley/comicsvalley.go new file mode 100644 index 0000000..f32b730 --- /dev/null +++ b/sources/all/comicsvalley/comicsvalley.go @@ -0,0 +1,20 @@ +package comicsvalley + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Comics Valley", + BaseURL: "https://comicsvalley.com", + Lang: "all", + MangaSubString: "comics-new", + UseNewChapterEndpoint: true, + }) +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/comikey/comikey.go b/sources/all/comikey/comikey.go new file mode 100644 index 0000000..16d56aa --- /dev/null +++ b/sources/all/comikey/comikey.go @@ -0,0 +1,381 @@ +// Package comikey implements the Comikey manga/webtoon source. +// Popular/latest/search: HTML scraping. Details: #comic JSON script tag. +// Chapters: Gundam API (gundam.comikey.net) with optional auth token. +// GetPageList: requires WebView DRM — not supported in the Go port; returns error. +package comikey + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient/flare" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const gundamURL = "https://gundam.comikey.net" + +type comikeyComic struct { + Link string `json:"link"` + Name string `json:"name"` + Author []struct{ Name string `json:"name"` } `json:"author"` + Artist []struct{ Name string `json:"name"` } `json:"artist"` + Tags []struct{ Name string `json:"name"` } `json:"tags"` + Description string `json:"description"` + Excerpt string `json:"excerpt"` + Format int `json:"format"` + FullCover string `json:"full_cover"` + UpdateStatus int `json:"update_status"` + UpdateText string `json:"update_text"` +} + +type comikeyEpisodeResp struct { + Episodes []comikeyEpisode `json:"episodes"` +} + +type comikeyEpisode struct { + ID string `json:"id"` + Number float32 `json:"number"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ReleasedAt string `json:"releasedAt"` + FinalPrice int `json:"finalPrice"` + Owned bool `json:"owned"` +} + +func (e comikeyEpisode) readable() bool { return e.FinalPrice == 0 || e.Owned } + +type Source struct { + name string + baseURL string + lang string + client *flare.Client + id int64 +} + +func newSource(lang, name, baseURL string) *Source { + return &Source{ + name: name, + baseURL: strings.TrimRight(baseURL, "/"), + lang: lang, + client: flare.NewClient(flare.WithRateLimit(3, 1)), + id: source.GenerateSourceID(name, lang), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.name } +func (s *Source) Lang() string { return s.lang } +func (s *Source) SupportsLatest() bool { return true } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.baseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("comikey: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func (s *Source) parseList(doc *goquery.Document) source.MangasPage { + var mangas []source.SManga + doc.Find("div.series-listing[data-view=list] > ul > li").Each(func(_ int, el *goquery.Selection) { + link := el.Find("div.series-data span.title a").First() + href := link.AttrOr("href", "") + title := strings.TrimSpace(link.Text()) + if href == "" || title == "" { + return + } + parsed, _ := url.Parse(href) + m := source.SManga{URL: parsed.RequestURI(), Title: title} + m.ThumbnailURL = el.Find("div.image picture img").First().AttrOr("src", "") + var genres []string + el.Find("ul.category-listing li a").Each(func(_ int, a *goquery.Selection) { + if t := strings.TrimSpace(a.Text()); t != "" { + genres = append(genres, t) + } + }) + m.Genre = strings.Join(genres, ", ") + mangas = append(mangas, m) + }) + hasNext := doc.Find("ul.pagination li.next-page:not(.disabled)").Length() > 0 + return source.MangasPage{Mangas: mangas, HasNextPage: hasNext} +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/comics/?order=-views&page=%d", s.baseURL, page)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseList(doc), nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + doc, err := s.get(context.Background(), fmt.Sprintf("%s/comics/?page=%d", s.baseURL, page)) + if err != nil { + return source.MangasPage{}, err + } + return s.parseList(doc), nil +} + +func (s *Source) GetSearchManga(page int, query string, filters []source.Filter) (source.MangasPage, error) { + params := url.Values{} + if page > 1 { + params.Set("page", fmt.Sprint(page)) + } + if len(query) >= 2 { + params.Set("q", query) + } + u := s.baseURL + "/comics/?" + if len(params) > 0 { + u += params.Encode() + } + doc, err := s.get(context.Background(), u) + if err != nil { + return source.MangasPage{}, err + } + return s.parseList(doc), nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + doc, err := s.get(context.Background(), s.baseURL+manga.URL) + if err != nil { + return manga, err + } + raw := doc.Find("script#comic").First().Text() + if raw == "" { + return manga, fmt.Errorf("comikey: #comic script not found") + } + var data comikeyComic + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return manga, err + } + result := source.SManga{URL: manga.URL} + result.Title = data.Name + result.ThumbnailURL = s.baseURL + data.FullCover + var authors []string + for _, a := range data.Author { + authors = append(authors, a.Name) + } + result.Author = strings.Join(authors, ", ") + var artists []string + for _, a := range data.Artist { + artists = append(artists, a.Name) + } + result.Artist = strings.Join(artists, ", ") + result.Description = strings.TrimSpace(`"` + data.Excerpt + `"` + "\n\n" + data.Description) + var genres []string + for _, t := range data.Tags { + genres = append(genres, t.Name) + } + switch data.Format { + case 0: + genres = append(genres, "Comic") + case 1: + genres = append(genres, "Manga") + case 2: + genres = append(genres, "Webtoon") + } + result.Genre = strings.Join(genres, ", ") + result.Status = comikeyStatus(data.UpdateStatus, data.UpdateText) + return result, nil +} + +func comikeyStatus(status int, updateText string) int { + switch { + case status == 1: + return source.StatusCompleted + case status == 3: + return source.StatusHiatus + case status >= 4 && status <= 14: + return source.StatusOngoing + case status == 0: + ut := strings.ToLower(updateText) + if strings.HasPrefix(ut, "toda") { + return source.StatusOngoing + } + if strings.HasPrefix(ut, "em pausa") || strings.HasPrefix(ut, "hiato") { + return source.StatusHiatus + } + } + return source.StatusUnknown +} + +// pathSegments splits a URL path like "/comics/overlord/76/" into ["comics","overlord","76"]. +func pathSegments(mangaURL string) []string { + return strings.FieldsFunc(mangaURL, func(r rune) bool { return r == '/' }) +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + doc, err := s.get(context.Background(), s.baseURL+manga.URL) + if err != nil { + return nil, err + } + + segs := pathSegments(manga.URL) + if len(segs) < 3 { + return nil, fmt.Errorf("comikey: unexpected manga URL format: %s", manga.URL) + } + mangaSlug := segs[1] // e.g. "overlord" + mangaID := segs[2] // e.g. "76" + + // Parse comic data to determine format (manga vs webtoon/episode). + chapterPrefix := "chapter" + if raw := doc.Find("script#comic").First().Text(); raw != "" { + var data comikeyComic + if json.Unmarshal([]byte(raw), &data) == nil && data.Format == 2 { + chapterPrefix = "episode" + } + } + + // Extract gundam token if present. + gundamToken := "" + doc.Find("script").Each(func(_ int, el *goquery.Selection) { + if strings.Contains(el.Text(), "GUNDAM.token") { + t := el.Text() + if idx := strings.Index(t, `= "`); idx >= 0 { + t = t[idx+3:] + if end := strings.Index(t, `";`); end >= 0 { + gundamToken = t[:end] + } + } + } + }) + + // Build gundam API URL. + var apiURL string + if gundamToken != "" { + apiURL = fmt.Sprintf("%s/comic/%s/episodes?language=%s&token=%s", + gundamURL, mangaID, strings.ToLower(s.lang), url.QueryEscape(gundamToken)) + } else { + apiURL = fmt.Sprintf("%s/comic.public/%s/episodes?language=%s", + gundamURL, mangaID, strings.ToLower(s.lang)) + } + + body, err := s.getAPIJSON(context.Background(), apiURL) + if err != nil { + return nil, err + } + var resp comikeyEpisodeResp + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + + now := time.Now().UnixMilli() + var chapters []source.SChapter + for _, ep := range resp.Episodes { + if !ep.readable() { + continue + } + date := parseComikeyDate(ep.ReleasedAt) + if date > now { + continue + } + chURL := fmt.Sprintf("/read/%s/%s", mangaSlug, makeEpisodeSlug(ep, chapterPrefix, s.lang)) + name := ep.Title + if ep.Subtitle != "" { + name += ": " + ep.Subtitle + } + chapters = append(chapters, source.SChapter{ + URL: chURL, + Name: name, + ChapterNumber: ep.Number, + DateUpload: date, + }) + } + // Reverse to newest-first. + for i, j := 0, len(chapters)-1; i < j; i, j = i+1, j-1 { + chapters[i], chapters[j] = chapters[j], chapters[i] + } + return chapters, nil +} + +func makeEpisodeSlug(ep comikeyEpisode, prefix, lang string) string { + parts := strings.SplitN(ep.ID, "-", 2) + e4pid := ep.ID + if len(parts) == 2 { + e4pid = parts[1] + } + locPrefix := prefix + if prefix == "chapter" && lang != "en" { + switch lang { + case "es": + locPrefix = "capitulo-espanol" + case "pt-BR": + locPrefix = "capitulo-portugues" + case "fr": + locPrefix = "chapitre-francais" + case "id": + locPrefix = "bab-bahasa" + } + } + numStr := fmt.Sprintf("%g", ep.Number) + numStr = strings.ReplaceAll(numStr, ".", "-") + return fmt.Sprintf("%s/%s-%s/", e4pid, locPrefix, numStr) +} + +func parseComikeyDate(s string) int64 { + t, err := time.Parse("2006-01-02T15:04:05Z", s) + if err != nil { + return 0 + } + return t.UnixMilli() +} + +func (s *Source) getAPIJSON(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Referer", s.baseURL+"/") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("comikey: gundam API HTTP %d", resp.StatusCode) + } + buf := make([]byte, 0, 4096) + tmp := make([]byte, 4096) + for { + n, err := resp.Body.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + break + } + } + return buf, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + return nil, fmt.Errorf("comikey: page list requires WebView/DRM — not supported in the Go port") +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func init() { + registry.Register(newSource("en", "Comikey", "https://comikey.com")) + registry.Register(newSource("es", "Comikey", "https://comikey.com")) + registry.Register(newSource("id", "Comikey", "https://comikey.com")) + registry.Register(newSource("pt-BR", "Comikey", "https://comikey.com")) + registry.Register(newSource("pt-BR", "Comikey Brasil", "https://br.comikey.com")) +} diff --git a/sources/all/commitstrip/commitstrip.go b/sources/all/commitstrip/commitstrip.go new file mode 100644 index 0000000..c9914db --- /dev/null +++ b/sources/all/commitstrip/commitstrip.go @@ -0,0 +1,223 @@ +// Package commitstrip implements the Commit Strip webcomic source. +// Two language instances (en, fr). Popular is a static list of per-year entries +// (2012 → current year). Chapters scraped from paginated year archives; one page per strip. +package commitstrip + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "goyomi/internal/httpclient" + "goyomi/internal/registry" + "goyomi/internal/source" +) + +const siteURL = "https://www.commitstrip.com" + +var ( + dateRe = regexp.MustCompile(`\d{4}/\d{2}/\d{2}`) + pageRe = regexp.MustCompile(`\d+`) +) + +type Source struct { + name string + lang string + siteLang string + client *httpclient.Client + id int64 +} + +func newSource(lang, siteLang string) *Source { + return &Source{ + name: "Commit Strip", + lang: lang, + siteLang: siteLang, + client: httpclient.NewClient(httpclient.WithRateLimit(2, 1)), + id: source.GenerateSourceID("Commit Strip", lang), + } +} + +func (s *Source) ID() int64 { return s.id } +func (s *Source) Name() string { return s.name } +func (s *Source) Lang() string { return s.lang } +func (s *Source) SupportsLatest() bool { return false } + +func (s *Source) get(ctx context.Context, rawURL string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("commitstrip: HTTP %d", resp.StatusCode) + } + return goquery.NewDocumentFromReader(resp.Body) +} + +func currentYear() int { return time.Now().Year() } + +func (s *Source) thumbnail() string { + if s.lang == "fr" { + return "https://i.imgur.com/I7ps9zS.jpg" + } + return "https://i.imgur.com/HODJlt9.jpg" +} + +func (s *Source) author() string { + if s.lang == "fr" { + return "Thomas Gx" + } + return "Mark Nightingale" +} + +func (s *Source) summary(year int) string { + note := fmt.Sprintf("\n\nNote: This entry includes all the chapters published in %d", year) + if s.lang == "fr" { + return "Le blog qui raconte la vie des codeurs" + note + } + return "The blog relating the daily life of web agency developers." + note +} + +func (s *Source) makeYearManga(year int) source.SManga { + status := source.StatusOngoing + if year != currentYear() { + status = source.StatusCompleted + } + return source.SManga{ + URL: fmt.Sprintf("/%s/%d", s.siteLang, year), + Title: fmt.Sprintf("Commit Strip (%d)", year), + ThumbnailURL: s.thumbnail(), + Author: s.author(), + Artist: "Etienne Issartial", + Status: status, + Description: s.summary(year), + } +} + +func (s *Source) GetPopularManga(page int) (source.MangasPage, error) { + if page > 1 { + return source.MangasPage{}, nil + } + cur := currentYear() + mangas := make([]source.SManga, 0, cur-2011) + for y := cur; y >= 2012; y-- { + mangas = append(mangas, s.makeYearManga(y)) + } + return source.MangasPage{Mangas: mangas, HasNextPage: false}, nil +} + +func (s *Source) GetLatestUpdates(page int) (source.MangasPage, error) { + return source.MangasPage{}, fmt.Errorf("commitstrip: latest not supported") +} + +func (s *Source) GetSearchManga(page int, query string, _ []source.Filter) (source.MangasPage, error) { + all, _ := s.GetPopularManga(1) + if query == "" { + return all, nil + } + q := strings.ToLower(query) + var matched []source.SManga + for _, m := range all.Mangas { + if strings.Contains(strings.ToLower(m.Title), q) { + matched = append(matched, m) + } + } + return source.MangasPage{Mangas: matched, HasNextPage: false}, nil +} + +func (s *Source) GetMangaDetails(manga source.SManga) (source.SManga, error) { + return manga, nil +} + +func (s *Source) GetChapterList(manga source.SManga) ([]source.SChapter, error) { + yearURL := siteURL + manga.URL + doc, err := s.get(context.Background(), yearURL) + if err != nil { + return nil, err + } + + // Find total pages from ".wp-pagenavi .pages" text (e.g. "Page 1 of 12"). + totalPages := 1 + if pagesText := doc.Find(".wp-pagenavi .pages").First().Text(); pagesText != "" { + matches := pageRe.FindAllString(pagesText, -1) + if len(matches) >= 2 { + fmt.Sscanf(matches[len(matches)-1], "%d", &totalPages) + } + } + + var chapters []source.SChapter + collect := func(d *goquery.Document) { + d.Find(".excerpt a").Each(func(_ int, a *goquery.Selection) { + href := a.AttrOr("href", "") + if href == "" { + return + } + chURL := strings.TrimPrefix(href, siteURL) + name := strings.TrimSpace(a.Find("span").Text()) + if name == "" { + name = strings.TrimSpace(a.Text()) + } + var date int64 + if m := dateRe.FindString(chURL); m != "" { + if t, err := time.Parse("2006/01/02", m); err == nil { + date = t.UnixMilli() + } + } + chapters = append(chapters, source.SChapter{URL: chURL, Name: name, DateUpload: date}) + }) + } + + collect(doc) + for pg := 2; pg <= totalPages; pg++ { + pageDoc, err := s.get(context.Background(), fmt.Sprintf("%s/page/%d", yearURL, pg)) + if err != nil { + break + } + collect(pageDoc) + } + + // Deduplicate and assign chapter numbers. + seen := make(map[string]bool) + var unique []source.SChapter + for _, ch := range chapters { + if !seen[ch.URL] { + seen[ch.URL] = true + unique = append(unique, ch) + } + } + total := len(unique) + for i := range unique { + unique[i].ChapterNumber = float32(total - i) + } + return unique, nil +} + +func (s *Source) GetPageList(chapter source.SChapter) ([]source.Page, error) { + doc, err := s.get(context.Background(), siteURL+chapter.URL) + if err != nil { + return nil, err + } + src := doc.Find(".entry-content p img").First().AttrOr("src", "") + if src == "" { + return nil, fmt.Errorf("commitstrip: image not found") + } + return []source.Page{{Index: 0, ImageURL: src}}, nil +} + +func (s *Source) GetImageURL(page source.Page) (string, error) { return page.ImageURL, nil } +func (s *Source) GetFilterList() []source.Filter { return nil } + +func init() { + registry.Register(newSource("en", "en")) + registry.Register(newSource("fr", "fr")) +} diff --git a/sources/all/coomer/coomer.go b/sources/all/coomer/coomer.go new file mode 100644 index 0000000..c556194 --- /dev/null +++ b/sources/all/coomer/coomer.go @@ -0,0 +1,18 @@ +package coomer + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/kemono" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Coomer", + BaseURL: "https://coomer.st", + Lang: "all", + }) +} + +func init() { + registry.Register(New()) +} diff --git a/sources/all/elitebabes/elitebabes.go b/sources/all/elitebabes/elitebabes.go new file mode 100644 index 0000000..e4bd8e1 --- /dev/null +++ b/sources/all/elitebabes/elitebabes.go @@ -0,0 +1,16 @@ +package elitebabes + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Elite Babes", + BaseURL: "https://www.elitebabes.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/femjoyhunter/femjoyhunter.go b/sources/all/femjoyhunter/femjoyhunter.go new file mode 100644 index 0000000..2ad98ac --- /dev/null +++ b/sources/all/femjoyhunter/femjoyhunter.go @@ -0,0 +1,16 @@ +package femjoyhunter + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Femjoy Hunter", + BaseURL: "https://www.femjoyhunter.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/ftvhunter/ftvhunter.go b/sources/all/ftvhunter/ftvhunter.go new file mode 100644 index 0000000..48617ad --- /dev/null +++ b/sources/all/ftvhunter/ftvhunter.go @@ -0,0 +1,16 @@ +package ftvhunter + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "FTV Hunter", + BaseURL: "https://www.ftvhunter.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/joymiihub/joymiihub.go b/sources/all/joymiihub/joymiihub.go new file mode 100644 index 0000000..f196de8 --- /dev/null +++ b/sources/all/joymiihub/joymiihub.go @@ -0,0 +1,16 @@ +package joymiihub + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Joymii Hub", + BaseURL: "https://www.joymiihub.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/mangacrazy/mangacrazy.go b/sources/all/mangacrazy/mangacrazy.go new file mode 100644 index 0000000..8e8685f --- /dev/null +++ b/sources/all/mangacrazy/mangacrazy.go @@ -0,0 +1,17 @@ +package mangacrazy + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "MangaCrazy", + BaseURL: "https://mangacrazy.net", + Lang: "all", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/metarthunter/metarthunter.go b/sources/all/metarthunter/metarthunter.go new file mode 100644 index 0000000..6d39926 --- /dev/null +++ b/sources/all/metarthunter/metarthunter.go @@ -0,0 +1,16 @@ +package metarthunter + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Metart Hunter", + BaseURL: "https://www.metarthunter.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/playmatehunter/playmatehunter.go b/sources/all/playmatehunter/playmatehunter.go new file mode 100644 index 0000000..e2850b7 --- /dev/null +++ b/sources/all/playmatehunter/playmatehunter.go @@ -0,0 +1,16 @@ +package playmatehunter + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Playmate Hunter", + BaseURL: "https://pmatehunter.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/all/xarthunter/xarthunter.go b/sources/all/xarthunter/xarthunter.go new file mode 100644 index 0000000..f5069e5 --- /dev/null +++ b/sources/all/xarthunter/xarthunter.go @@ -0,0 +1,16 @@ +package xarthunter + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/masonry" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "XArt Hunter", + BaseURL: "https://www.xarthunter.com", + Lang: "all", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/aquamanga/aquamanga.go b/sources/en/aquamanga/aquamanga.go new file mode 100644 index 0000000..cecaabb --- /dev/null +++ b/sources/en/aquamanga/aquamanga.go @@ -0,0 +1,16 @@ +package aquamanga + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Aqua Manga", + BaseURL: "https://aquareader.net", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/arenascans/arenascans.go b/sources/en/arenascans/arenascans.go new file mode 100644 index 0000000..178e5b0 --- /dev/null +++ b/sources/en/arenascans/arenascans.go @@ -0,0 +1,16 @@ +package arenascans + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Arena Scans", + BaseURL: "https://arenascan.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/artlapsa/artlapsa.go b/sources/en/artlapsa/artlapsa.go new file mode 100644 index 0000000..c69c764 --- /dev/null +++ b/sources/en/artlapsa/artlapsa.go @@ -0,0 +1,16 @@ +package artlapsa + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/keyoapp" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Art Lapsa", + BaseURL: "https://artlapsa.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/cocomic/cocomic.go b/sources/en/cocomic/cocomic.go new file mode 100644 index 0000000..e77332a --- /dev/null +++ b/sources/en/cocomic/cocomic.go @@ -0,0 +1,17 @@ +package cocomic + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Cocomic", + BaseURL: "https://cocomic.co", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/comicsland/comicsland.go b/sources/en/comicsland/comicsland.go new file mode 100644 index 0000000..54ff17c --- /dev/null +++ b/sources/en/comicsland/comicsland.go @@ -0,0 +1,16 @@ +package comicsland + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Comics Land", + BaseURL: "https://comicsland.org", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/crowscans/crowscans.go b/sources/en/crowscans/crowscans.go new file mode 100644 index 0000000..063fcab --- /dev/null +++ b/sources/en/crowscans/crowscans.go @@ -0,0 +1,16 @@ +package crowscans + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Crow Scans", + BaseURL: "https://crowscans.xyz", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/dankefurslesen/dankefurslesen.go b/sources/en/dankefurslesen/dankefurslesen.go new file mode 100644 index 0000000..8501ce9 --- /dev/null +++ b/sources/en/dankefurslesen/dankefurslesen.go @@ -0,0 +1,16 @@ +package dankefurslesen + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/guya" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Danke fürs Lesen", + BaseURL: "https://danke.moe", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/elftoon/elftoon.go b/sources/en/elftoon/elftoon.go new file mode 100644 index 0000000..0584c38 --- /dev/null +++ b/sources/en/elftoon/elftoon.go @@ -0,0 +1,16 @@ +package elftoon + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Elf Toon", + BaseURL: "https://elftoon.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/gakamangas/gakamangas.go b/sources/en/gakamangas/gakamangas.go new file mode 100644 index 0000000..b11b2c2 --- /dev/null +++ b/sources/en/gakamangas/gakamangas.go @@ -0,0 +1,17 @@ +package gakamangas + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "GakaMangas", + BaseURL: "https://gakamangas.com", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/galaxydegenscans/galaxydegenscans.go b/sources/en/galaxydegenscans/galaxydegenscans.go new file mode 100644 index 0000000..7759e81 --- /dev/null +++ b/sources/en/galaxydegenscans/galaxydegenscans.go @@ -0,0 +1,16 @@ +package galaxydegenscans + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "GalaxyDegenScans", + BaseURL: "https://gdscans.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/hachirumi/hachirumi.go b/sources/en/hachirumi/hachirumi.go new file mode 100644 index 0000000..ba9ea93 --- /dev/null +++ b/sources/en/hachirumi/hachirumi.go @@ -0,0 +1,16 @@ +package hachirumi + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/guya" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Hachirumi", + BaseURL: "https://hachirumi.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/hentai4free/hentai4free.go b/sources/en/hentai4free/hentai4free.go new file mode 100644 index 0000000..7b1d1a3 --- /dev/null +++ b/sources/en/hentai4free/hentai4free.go @@ -0,0 +1,18 @@ +package hentai4free + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Hentai4Free", + BaseURL: "https://hentai4free.net", + Lang: "en", + MangaSubString: "hentai", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/jinmangas/jinmangas.go b/sources/en/jinmangas/jinmangas.go new file mode 100644 index 0000000..af9cc26 --- /dev/null +++ b/sources/en/jinmangas/jinmangas.go @@ -0,0 +1,17 @@ +package jinmangas + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Jinmangas", + BaseURL: "https://jinmangas.com", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/kaliscanme/kaliscanme.go b/sources/en/kaliscanme/kaliscanme.go new file mode 100644 index 0000000..2b7de4b --- /dev/null +++ b/sources/en/kaliscanme/kaliscanme.go @@ -0,0 +1,16 @@ +package kaliscanme + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madtheme" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "KaliScan.me", + BaseURL: "https://kaliscan.me", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/kingofshojo/kingofshojo.go b/sources/en/kingofshojo/kingofshojo.go new file mode 100644 index 0000000..507ba7a --- /dev/null +++ b/sources/en/kingofshojo/kingofshojo.go @@ -0,0 +1,16 @@ +package kingofshojo + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "King of Shojo", + BaseURL: "https://kingofshojo.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/kissmangain/kissmangain.go b/sources/en/kissmangain/kissmangain.go new file mode 100644 index 0000000..647b108 --- /dev/null +++ b/sources/en/kissmangain/kissmangain.go @@ -0,0 +1,17 @@ +package kissmangain + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Kissmanga.in", + BaseURL: "https://kissmanga.in", + Lang: "en", + MangaSubString: "kissmanga", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/kunmanga/kunmanga.go b/sources/en/kunmanga/kunmanga.go new file mode 100644 index 0000000..90c248b --- /dev/null +++ b/sources/en/kunmanga/kunmanga.go @@ -0,0 +1,16 @@ +package kunmanga + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Kun Manga", + BaseURL: "https://kunmanga.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/lhtranslation/lhtranslation.go b/sources/en/lhtranslation/lhtranslation.go new file mode 100644 index 0000000..06f8793 --- /dev/null +++ b/sources/en/lhtranslation/lhtranslation.go @@ -0,0 +1,17 @@ +package lhtranslation + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "LHTranslation", + BaseURL: "https://lhtranslation.net", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/manga18x/manga18x.go b/sources/en/manga18x/manga18x.go new file mode 100644 index 0000000..3b4bd2c --- /dev/null +++ b/sources/en/manga18x/manga18x.go @@ -0,0 +1,17 @@ +package manga18x + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Manga 18x", + BaseURL: "https://manga18x.net", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/mangagg/mangagg.go b/sources/en/mangagg/mangagg.go new file mode 100644 index 0000000..97dba99 --- /dev/null +++ b/sources/en/mangagg/mangagg.go @@ -0,0 +1,16 @@ +package mangagg + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "MangaGG", + BaseURL: "https://mangagg.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/mangahe/mangahe.go b/sources/en/mangahe/mangahe.go new file mode 100644 index 0000000..be39176 --- /dev/null +++ b/sources/en/mangahe/mangahe.go @@ -0,0 +1,16 @@ +package mangahe + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "MangaHe", + BaseURL: "https://mangahe.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/mangahentai/mangahentai.go b/sources/en/mangahentai/mangahentai.go new file mode 100644 index 0000000..cc344ce --- /dev/null +++ b/sources/en/mangahentai/mangahentai.go @@ -0,0 +1,17 @@ +package mangahentai + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Manga Hentai", + BaseURL: "https://mangahentai.me", + Lang: "en", + MangaSubString: "manga-hentai", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/manhwalover/manhwalover.go b/sources/en/manhwalover/manhwalover.go new file mode 100644 index 0000000..e434313 --- /dev/null +++ b/sources/en/manhwalover/manhwalover.go @@ -0,0 +1,16 @@ +package manhwalover + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Manhwalover", + BaseURL: "https://www.manhwalover.org", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/manhwareads/manhwareads.go b/sources/en/manhwareads/manhwareads.go new file mode 100644 index 0000000..5b98492 --- /dev/null +++ b/sources/en/manhwareads/manhwareads.go @@ -0,0 +1,17 @@ +package manhwareads + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Manhwa Reads", + BaseURL: "https://manhwareads.com", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/manhwatop/manhwatop.go b/sources/en/manhwatop/manhwatop.go new file mode 100644 index 0000000..5929a02 --- /dev/null +++ b/sources/en/manhwatop/manhwatop.go @@ -0,0 +1,16 @@ +package manhwatop + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Manhwatop", + BaseURL: "https://manhwatop.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/manhwax/manhwax.go b/sources/en/manhwax/manhwax.go new file mode 100644 index 0000000..9f5385a --- /dev/null +++ b/sources/en/manhwax/manhwax.go @@ -0,0 +1,16 @@ +package manhwax + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Manhwax", + BaseURL: "https://manhwax.top", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/mgjinx/mgjinx.go b/sources/en/mgjinx/mgjinx.go new file mode 100644 index 0000000..83ecfa2 --- /dev/null +++ b/sources/en/mgjinx/mgjinx.go @@ -0,0 +1,16 @@ +package mgjinx + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madtheme" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "MGJinx", + BaseURL: "https://mgjinx.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/octopusmanga/octopusmanga.go b/sources/en/octopusmanga/octopusmanga.go new file mode 100644 index 0000000..5d8f471 --- /dev/null +++ b/sources/en/octopusmanga/octopusmanga.go @@ -0,0 +1,17 @@ +package octopusmanga + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "OctopusManga", + BaseURL: "https://octopusmanga.com", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/pawmanga/pawmanga.go b/sources/en/pawmanga/pawmanga.go new file mode 100644 index 0000000..b023ce7 --- /dev/null +++ b/sources/en/pawmanga/pawmanga.go @@ -0,0 +1,17 @@ +package pawmanga + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Paw Manga", + BaseURL: "https://pawmanga.com", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/ravenscans/ravenscans.go b/sources/en/ravenscans/ravenscans.go new file mode 100644 index 0000000..de75d4d --- /dev/null +++ b/sources/en/ravenscans/ravenscans.go @@ -0,0 +1,16 @@ +package ravenscans + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Raven Scans", + BaseURL: "https://ravenscans.org", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/ritharscans/ritharscans.go b/sources/en/ritharscans/ritharscans.go new file mode 100644 index 0000000..8d4fde9 --- /dev/null +++ b/sources/en/ritharscans/ritharscans.go @@ -0,0 +1,16 @@ +package ritharscans + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/keyoapp" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "RitharScans", + BaseURL: "https://ritharscans.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/rosesquadscans/rosesquadscans.go b/sources/en/rosesquadscans/rosesquadscans.go new file mode 100644 index 0000000..9318c72 --- /dev/null +++ b/sources/en/rosesquadscans/rosesquadscans.go @@ -0,0 +1,17 @@ +package rosesquadscans + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Rose Squad Scans", + BaseURL: "https://rosesquadscans.aishiteru.org", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/ryumanga/ryumanga.go b/sources/en/ryumanga/ryumanga.go new file mode 100644 index 0000000..b6f863f --- /dev/null +++ b/sources/en/ryumanga/ryumanga.go @@ -0,0 +1,16 @@ +package ryumanga + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/keyoapp" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Ryumanga", + BaseURL: "https://ryumanga.org", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/tcbscansunoriginal/tcbscansunoriginal.go b/sources/en/tcbscansunoriginal/tcbscansunoriginal.go new file mode 100644 index 0000000..03dd8aa --- /dev/null +++ b/sources/en/tcbscansunoriginal/tcbscansunoriginal.go @@ -0,0 +1,16 @@ +package tcbscansunoriginal + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/mangathemesia" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "TCB Scans (Unoriginal)", + BaseURL: "https://tcbscanonepiecechapters.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/toonilyme/toonilyme.go b/sources/en/toonilyme/toonilyme.go new file mode 100644 index 0000000..7d66458 --- /dev/null +++ b/sources/en/toonilyme/toonilyme.go @@ -0,0 +1,16 @@ +package toonilyme + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madtheme" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Toonily.me", + BaseURL: "https://toonily.me", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/toonitube/toonitube.go b/sources/en/toonitube/toonitube.go new file mode 100644 index 0000000..616d9d1 --- /dev/null +++ b/sources/en/toonitube/toonitube.go @@ -0,0 +1,16 @@ +package toonitube + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madtheme" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "TooniTube", + BaseURL: "https://toonitube.com", + Lang: "en", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/topmanhua/topmanhua.go b/sources/en/topmanhua/topmanhua.go new file mode 100644 index 0000000..f890ac2 --- /dev/null +++ b/sources/en/topmanhua/topmanhua.go @@ -0,0 +1,17 @@ +package topmanhua + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Top Manhua", + BaseURL: "https://mangatop.org", + Lang: "en", + MangaSubString: "manhua", + }) +} + +func init() { registry.Register(New()) } diff --git a/sources/en/yaoihub/yaoihub.go b/sources/en/yaoihub/yaoihub.go new file mode 100644 index 0000000..7f4bd0d --- /dev/null +++ b/sources/en/yaoihub/yaoihub.go @@ -0,0 +1,17 @@ +package yaoihub + +import ( + "goyomi/internal/registry" + base "goyomi/sources/base/madara" +) + +func New() *base.Source { + return base.New(base.Config{ + Name: "Yaoihub", + BaseURL: "https://yaoihub.net", + Lang: "en", + UseNewChapterEndpoint: true, + }) +} + +func init() { registry.Register(New()) }