feat: implement phase 4 batch — 54 base-class wrapper sources
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.
This commit is contained in:
@@ -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() {
|
||||
|
||||
+126
-104
@@ -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`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
Reference in New Issue
Block a user