refactor: Overhaul Plugin Architecture & Playground UI (#1846)

* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
This commit is contained in:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
+43
View File
@@ -0,0 +1,43 @@
# Readnovelfull multisrc generator
## Compatiblity
### This generator is brought over from the madara generator one folder away
it should(!) work for all sites that uses this theme. If not, edit.
## Add a new source
### sources.json
To add a new source you need to add it to sources.json:
- id: the id of the source (something unique)
- sourceName: the name of the source (you can use the value of "name" in "https://site.com/wp-json/" if it exists)
- sourceSite: the site url
- options: the options of the source
- lang?: the language of the source (default: "English") (check that the language
exists in the languages (check folder names in "plugins/"))
- versionIncrements?: needs to be updated everytime the site url is updated
- latestPage: the href value using this CSS selector `a[title="Latest Release"]`
- searchPage: path for search
- chapterListing?: if chapters are obtained from an ajax endpoint different from what is default.
- chapterParam?: same with chapterListing, but its the key for the form body. default: novelId
- pageParam?: if a site uses something other than "page" for accessing pages
// All the following is for when if a page uses a main path for querying novels and just adds params to it to get different outputs (see urls in lightnovelplus)
- novelListing?: Main path for browsing novels
- typeParam?: Field for entering the type of novelListing (eg: latest, hot, completed, etc), default: type
- genreParam?: Field for entering type of novelListing dedicated for genres (since param websites dont use the path change), default: category_novel
- genreKey?: Field for entering key representing the genre within the genreParam, default: id (eg: ?type=category_novel&id={value})
- langParam?: Field for entering the key representing language(?), do not understand reason for this param honestly, but recreate normal website behaviour as much as possible, default: none.
- urlLangCode?: the value for langParam, default: none. eg: 'en'
### filters
To add filters to a source you need to run the script "get_filters.js" \
(`npx node plugins/multisrc/readnovelfull/get_filters.js`
(if you are at the root of the project) (and you have ran "npm install" before))
and follow the instructions (url is easier and faster but sometimes it doesn't work)
@@ -0,0 +1,78 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Type",
"value": "sort/most-popular",
"options": [
{
"label": "All",
"value": "sort/latest-release"
},
{
"label": "Chinese Novel",
"value": "sort/latest-release/chinese-novel"
},
{
"label": "Korean Novel",
"value": "sort/latest-release/korean-novel"
},
{
"label": "Japanese Novel",
"value": "sort/latest-release/japanese-novel"
},
{
"label": "English Novel",
"value": "sort/latest-release/english-novel"
},
{
"label": "Most Popular",
"value": "sort/most-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{ "label": "Action", "value": "genre/Action" },
{ "label": "Adult", "value": "genre/Adult" },
{ "label": "Adventure", "value": "genre/Adventure" },
{ "label": "Comedy", "value": "genre/Comedy" },
{ "label": "Drama", "value": "genre/Drama" },
{ "label": "Eastern", "value": "genre/Eastern" },
{ "label": "Ecchi", "value": "genre/Ecchi" },
{ "label": "Fantasy", "value": "genre/Fantasy" },
{ "label": "Game", "value": "genre/Game" },
{ "label": "Gender Bender", "value": "genre/Gender+Bender" },
{ "label": "Harem", "value": "genre/Harem" },
{ "label": "Historical", "value": "genre/Historical" },
{ "label": "Horror", "value": "genre/Horror" },
{ "label": "Josei", "value": "genre/Josei" },
{ "label": "Martial Arts", "value": "genre/Martial+Arts" },
{ "label": "Mature", "value": "genre/Mature" },
{ "label": "Mecha", "value": "genre/Mecha" },
{ "label": "Mystery", "value": "genre/Mystery" },
{ "label": "Psychological", "value": "genre/Psychological" },
{ "label": "Reincarnation", "value": "genre/Reincarnation" },
{ "label": "Romance", "value": "genre/Romance" },
{ "label": "School Life", "value": "genre/School+Life" },
{ "label": "Sci-fi", "value": "genre/Sci-fi" },
{ "label": "Seinen", "value": "genre/Seinen" },
{ "label": "Shoujo", "value": "genre/Shoujo" },
{ "label": "Shounen Ai", "value": "genre/Shounen+Ai" },
{ "label": "Shounen", "value": "genre/Shounen" },
{ "label": "Slice of Life", "value": "genre/Slice+of+Life" },
{ "label": "Smut", "value": "genre/Smut" },
{ "label": "Sports", "value": "genre/Sports" },
{ "label": "Supernatural", "value": "genre/Supernatural" },
{ "label": "Tragedy", "value": "genre/Tragedy" },
{ "label": "Wuxia", "value": "genre/Wuxia" },
{ "label": "Xianxia", "value": "genre/Xianxia" },
{ "label": "Xuanhuan", "value": "genre/Xuanhuan" },
{ "label": "Yaoi", "value": "genre/Yaoi" }
]
}
}
}
@@ -0,0 +1,174 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "most-popular",
"options": [
{
"label": "Hot Novel",
"value": "hot-novel"
},
{
"label": "Completed Novel",
"value": "completed-novel"
},
{
"label": "Most Popular",
"value": "most-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Shounen",
"value": "genre/Shounen"
},
{
"label": "Harem",
"value": "genre/Harem"
},
{
"label": "Comedy",
"value": "genre/Comedy"
},
{
"label": "Martial Arts",
"value": "genre/Martial+Arts"
},
{
"label": "School Life",
"value": "genre/School+Life"
},
{
"label": "Mystery",
"value": "genre/Mystery"
},
{
"label": "Shoujo",
"value": "genre/Shoujo"
},
{
"label": "Romance",
"value": "genre/Romance"
},
{
"label": "Sci-fi",
"value": "genre/Sci-fi"
},
{
"label": "Gender Bender",
"value": "genre/Gender+Bender"
},
{
"label": "Mature",
"value": "genre/Mature"
},
{
"label": "Fantasy",
"value": "genre/Fantasy"
},
{
"label": "Horror",
"value": "genre/Horror"
},
{
"label": "Drama",
"value": "genre/Drama"
},
{
"label": "Tragedy",
"value": "genre/Tragedy"
},
{
"label": "Supernatural",
"value": "genre/Supernatural"
},
{
"label": "Ecchi",
"value": "genre/Ecchi"
},
{
"label": "Xuanhuan",
"value": "genre/Xuanhuan"
},
{
"label": "Adventure",
"value": "genre/Adventure"
},
{
"label": "Action",
"value": "genre/Action"
},
{
"label": "Psychological",
"value": "genre/Psychological"
},
{
"label": "Xianxia",
"value": "genre/Xianxia"
},
{
"label": "Wuxia",
"value": "genre/Wuxia"
},
{
"label": "Historical",
"value": "genre/Historical"
},
{
"label": "Slice of Life",
"value": "genre/Slice+of+Life"
},
{
"label": "Seinen",
"value": "genre/Seinen"
},
{
"label": "Lolicon",
"value": "genre/Lolicon"
},
{
"label": "Adult",
"value": "genre/Adult"
},
{
"label": "Josei",
"value": "genre/Josei"
},
{
"label": "Sports",
"value": "genre/Sports"
},
{
"label": "Smut",
"value": "genre/Smut"
},
{
"label": "Mecha",
"value": "genre/Mecha"
},
{
"label": "Yaoi",
"value": "genre/Yaoi"
},
{
"label": "Shounen Ai",
"value": "genre/Shounen+Ai"
},
{
"label": "History",
"value": "genre/History"
},
{
"label": "Reincarnation",
"value": "genre/Reincarnation"
}
]
}
}
}
@@ -0,0 +1,174 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "most-popular",
"options": [
{
"label": "Hot Novel",
"value": "hot-novel"
},
{
"label": "Completed Novel",
"value": "completed-novel"
},
{
"label": "Most Popular",
"value": "most-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Shounen",
"value": "genre/Shounen"
},
{
"label": "Harem",
"value": "genre/Harem"
},
{
"label": "Comedy",
"value": "genre/Comedy"
},
{
"label": "Martial Arts",
"value": "genre/Martial+Arts"
},
{
"label": "School Life",
"value": "genre/School+Life"
},
{
"label": "Mystery",
"value": "genre/Mystery"
},
{
"label": "Shoujo",
"value": "genre/Shoujo"
},
{
"label": "Romance",
"value": "genre/Romance"
},
{
"label": "Sci-fi",
"value": "genre/Sci-fi"
},
{
"label": "Gender Bender",
"value": "genre/Gender+Bender"
},
{
"label": "Mature",
"value": "genre/Mature"
},
{
"label": "Fantasy",
"value": "genre/Fantasy"
},
{
"label": "Horror",
"value": "genre/Horror"
},
{
"label": "Drama",
"value": "genre/Drama"
},
{
"label": "Tragedy",
"value": "genre/Tragedy"
},
{
"label": "Supernatural",
"value": "genre/Supernatural"
},
{
"label": "Ecchi",
"value": "genre/Ecchi"
},
{
"label": "Xuanhuan",
"value": "genre/Xuanhuan"
},
{
"label": "Adventure",
"value": "genre/Adventure"
},
{
"label": "Action",
"value": "genre/Action"
},
{
"label": "Psychological",
"value": "genre/Psychological"
},
{
"label": "Xianxia",
"value": "genre/Xianxia"
},
{
"label": "Wuxia",
"value": "genre/Wuxia"
},
{
"label": "Historical",
"value": "genre/Historical"
},
{
"label": "Slice of Life",
"value": "genre/Slice+of+Life"
},
{
"label": "Seinen",
"value": "genre/Seinen"
},
{
"label": "Lolicon",
"value": "genre/Lolicon"
},
{
"label": "Adult",
"value": "genre/Adult"
},
{
"label": "Josei",
"value": "genre/Josei"
},
{
"label": "Sports",
"value": "genre/Sports"
},
{
"label": "Smut",
"value": "genre/Smut"
},
{
"label": "Mecha",
"value": "genre/Mecha"
},
{
"label": "Yaoi",
"value": "genre/Yaoi"
},
{
"label": "Shounen Ai",
"value": "genre/Shounen+Ai"
},
{
"label": "Magical Realism",
"value": "genre/Magical+Realism"
},
{
"label": "Video Games",
"value": "genre/Video+Games"
}
]
}
}
}
@@ -0,0 +1,254 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "sort/nov-love-popular",
"options": [
{
"label": "Hot Novel",
"value": "sort/nov-love-hot"
},
{
"label": "Completed Novel",
"value": "sort/nov-love-complete"
},
{
"label": "Most Popular",
"value": "sort/nov-love-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Action",
"value": "nov-love-genres/action"
},
{
"label": "Adventure",
"value": "nov-love-genres/adventure"
},
{
"label": "Anime & comics",
"value": "nov-love-genres/anime-&-comics"
},
{
"label": "Comedy",
"value": "nov-love-genres/comedy"
},
{
"label": "Drama",
"value": "nov-love-genres/drama"
},
{
"label": "Eastern",
"value": "nov-love-genres/eastern"
},
{
"label": "Fan-fiction",
"value": "nov-love-genres/fan-fiction"
},
{
"label": "Fanfiction",
"value": "nov-love-genres/fanfiction"
},
{
"label": "Fantasy",
"value": "nov-love-genres/fantasy"
},
{
"label": "Game",
"value": "nov-love-genres/game"
},
{
"label": "Games",
"value": "nov-love-genres/games"
},
{
"label": "Gender bender",
"value": "nov-love-genres/gender-bender"
},
{
"label": "General",
"value": "nov-love-genres/general"
},
{
"label": "Harem",
"value": "nov-love-genres/harem"
},
{
"label": "Historical",
"value": "nov-love-genres/historical"
},
{
"label": "Horror",
"value": "nov-love-genres/horror"
},
{
"label": "Isekai",
"value": "nov-love-genres/isekai"
},
{
"label": "Josei",
"value": "nov-love-genres/josei"
},
{
"label": "Litrpg",
"value": "nov-love-genres/litrpg"
},
{
"label": "Magic",
"value": "nov-love-genres/magic"
},
{
"label": "Magical realism",
"value": "nov-love-genres/magical-realism"
},
{
"label": "Martial arts",
"value": "nov-love-genres/martial-arts"
},
{
"label": "Mature",
"value": "nov-love-genres/mature"
},
{
"label": "Mecha",
"value": "nov-love-genres/mecha"
},
{
"label": "Modern life",
"value": "nov-love-genres/modern-life"
},
{
"label": "Myster",
"value": "nov-love-genres/myster"
},
{
"label": "Mystery",
"value": "nov-love-genres/mystery"
},
{
"label": "Other",
"value": "nov-love-genres/other"
},
{
"label": "Psychological",
"value": "nov-love-genres/psychological"
},
{
"label": "Reincarnation",
"value": "nov-love-genres/reincarnation"
},
{
"label": "Report story",
"value": "nov-love-genres/report-story"
},
{
"label": "Romance",
"value": "nov-love-genres/romance"
},
{
"label": "Romance.smut",
"value": "nov-love-genres/romance.smut"
},
{
"label": "School life",
"value": "nov-love-genres/school-life"
},
{
"label": "Sci-fi",
"value": "nov-love-genres/sci-fi"
},
{
"label": "Seinen",
"value": "nov-love-genres/seinen"
},
{
"label": "Shoujo",
"value": "nov-love-genres/shoujo"
},
{
"label": "Shoujo ai",
"value": "nov-love-genres/shoujo-ai"
},
{
"label": "Shounen",
"value": "nov-love-genres/shounen"
},
{
"label": "Shounen ai",
"value": "nov-love-genres/shounen-ai"
},
{
"label": "Slice of life",
"value": "nov-love-genres/slice-of-life"
},
{
"label": "Smut",
"value": "nov-love-genres/smut"
},
{
"label": "Sports",
"value": "nov-love-genres/sports"
},
{
"label": "Supernatural",
"value": "nov-love-genres/supernatural"
},
{
"label": "System",
"value": "nov-love-genres/system"
},
{
"label": "Thriller",
"value": "nov-love-genres/thriller"
},
{
"label": "Tragedy",
"value": "nov-love-genres/tragedy"
},
{
"label": "Urban",
"value": "nov-love-genres/urban"
},
{
"label": "Urban life",
"value": "nov-love-genres/urban-life"
},
{
"label": "Video games",
"value": "nov-love-genres/video-games"
},
{
"label": "War",
"value": "nov-love-genres/war"
},
{
"label": "Wuxia",
"value": "nov-love-genres/wuxia"
},
{
"label": "Xianxia",
"value": "nov-love-genres/xianxia"
},
{
"label": "Xuanhuan",
"value": "nov-love-genres/xuanhuan"
},
{
"label": "Yaoi",
"value": "nov-love-genres/yaoi"
},
{
"label": "Yuri",
"value": "nov-love-genres/yuri"
}
]
}
}
}
@@ -0,0 +1,78 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Type",
"value": "sort/most-popular",
"options": [
{
"label": "All",
"value": "sort/latest-release"
},
{
"label": "Chinese Novel",
"value": "sort/latest-release/chinese-novel"
},
{
"label": "Korean Novel",
"value": "sort/latest-release/korean-novel"
},
{
"label": "Japanese Novel",
"value": "sort/latest-release/japanese-novel"
},
{
"label": "English Novel",
"value": "sort/latest-release/english-novel"
},
{
"label": "Most Popular",
"value": "sort/most-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{ "label": "Action", "value": "genre/Action" },
{ "label": "Adult", "value": "genre/Adult" },
{ "label": "Adventure", "value": "genre/Adventure" },
{ "label": "Comedy", "value": "genre/Comedy" },
{ "label": "Drama", "value": "genre/Drama" },
{ "label": "Eastern", "value": "genre/Eastern" },
{ "label": "Ecchi", "value": "genre/Ecchi" },
{ "label": "Fantasy", "value": "genre/Fantasy" },
{ "label": "Game", "value": "genre/Game" },
{ "label": "Gender Bender", "value": "genre/Gender+Bender" },
{ "label": "Harem", "value": "genre/Harem" },
{ "label": "Historical", "value": "genre/Historical" },
{ "label": "Horror", "value": "genre/Horror" },
{ "label": "Josei", "value": "genre/Josei" },
{ "label": "Martial Arts", "value": "genre/Martial+Arts" },
{ "label": "Mature", "value": "genre/Mature" },
{ "label": "Mecha", "value": "genre/Mecha" },
{ "label": "Mystery", "value": "genre/Mystery" },
{ "label": "Psychological", "value": "genre/Psychological" },
{ "label": "Reincarnation", "value": "genre/Reincarnation" },
{ "label": "Romance", "value": "genre/Romance" },
{ "label": "School Life", "value": "genre/School+Life" },
{ "label": "Sci-fi", "value": "genre/Sci-fi" },
{ "label": "Seinen", "value": "genre/Seinen" },
{ "label": "Shoujo", "value": "genre/Shoujo" },
{ "label": "Shounen Ai", "value": "genre/Shounen+Ai" },
{ "label": "Shounen", "value": "genre/Shounen" },
{ "label": "Slice of Life", "value": "genre/Slice+of+Life" },
{ "label": "Smut", "value": "genre/Smut" },
{ "label": "Sports", "value": "genre/Sports" },
{ "label": "Supernatural", "value": "genre/Supernatural" },
{ "label": "Tragedy", "value": "genre/Tragedy" },
{ "label": "Wuxia", "value": "genre/Wuxia" },
{ "label": "Xianxia", "value": "genre/Xianxia" },
{ "label": "Xuanhuan", "value": "genre/Xuanhuan" },
{ "label": "Yaoi", "value": "genre/Yaoi" }
]
}
}
}
@@ -0,0 +1,130 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "hot_novel",
"options": [
{
"label": "Hot Novel",
"value": "hot_novel"
},
{
"label": "Novel Completed",
"value": "completed_novel"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Fantasy",
"value": "60"
},
{
"label": "Action",
"value": "132"
},
{
"label": "Sci-fi",
"value": "61"
},
{
"label": "Romance",
"value": "59"
},
{
"label": "Adventure",
"value": "62"
},
{
"label": "Xuanhuan",
"value": "64"
},
{
"label": "Modern",
"value": "66"
},
{
"label": "Mystery",
"value": "63"
},
{
"label": "Romance",
"value": "68"
},
{
"label": "Fantasy",
"value": "70"
},
{
"label": "Historical",
"value": "74"
},
{
"label": "Sci-fi",
"value": "75"
},
{
"label": "Xuanhuan",
"value": "76"
},
{
"label": "Mystery",
"value": "77"
},
{
"label": "Adventure",
"value": "116"
},
{
"label": "LGBT+",
"value": "182"
},
{
"label": "Fantasy Romance",
"value": "134"
},
{
"label": "Video Games",
"value": "243"
},
{
"label": "Sci-fi Romance",
"value": "252"
},
{
"label": "Historical Romance",
"value": "256"
},
{
"label": "Magical Realism",
"value": "331"
},
{
"label": "Eastern Fantasy",
"value": "334"
},
{
"label": "Contemporary Romance",
"value": "344"
},
{
"label": "Games",
"value": "503"
},
{
"label": "Urban",
"value": "504"
},
{
"label": "Harem",
"value": "517"
}
]
}
}
}
@@ -0,0 +1,263 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "sort/top-view-novel",
"options": [
{
"label": "Hot Novel",
"value": "sort/top-hot-novel"
},
{
"label": "Completed Novel",
"value": "sort/completed"
},
{
"label": "Most Popular",
"value": "sort/top-view-novel"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre (Cancels out 'Novel Listing')",
"value": "",
"options": [
{
"label": "Action",
"value": "genre/action"
},
{
"label": "Adventure",
"value": "genre/adventure"
},
{
"label": "Anime & comics",
"value": "genre/anime-&-comics"
},
{
"label": "Comedy",
"value": "genre/comedy"
},
{
"label": "Drama",
"value": "genre/drama"
},
{
"label": "Eastern",
"value": "genre/eastern"
},
{
"label": "Fan-fiction",
"value": "genre/fan-fiction"
},
{
"label": "Fanfiction",
"value": "genre/fanfiction"
},
{
"label": "Fantasy",
"value": "genre/fantasy"
},
{
"label": "Game",
"value": "genre/game"
},
{
"label": "Games",
"value": "genre/games"
},
{
"label": "Gender bender",
"value": "genre/gender-bender"
},
{
"label": "General",
"value": "genre/general"
},
{
"label": "Harem",
"value": "genre/harem"
},
{
"label": "Historical",
"value": "genre/historical"
},
{
"label": "Horror",
"value": "genre/horror"
},
{
"label": "Isekai",
"value": "genre/isekai"
},
{
"label": "Josei",
"value": "genre/josei"
},
{
"label": "Litrpg",
"value": "genre/litrpg"
},
{
"label": "Magic",
"value": "genre/magic"
},
{
"label": "Magical realism",
"value": "genre/magical-realism"
},
{
"label": "Martial arts",
"value": "genre/martial-arts"
},
{
"label": "Mature",
"value": "genre/mature"
},
{
"label": "Mecha",
"value": "genre/mecha"
},
{
"label": "Military",
"value": "genre/military"
},
{
"label": "Modern life",
"value": "genre/modern-life"
},
{
"label": "Myster",
"value": "genre/myster"
},
{
"label": "Mystery",
"value": "genre/mystery"
},
{
"label": "Other",
"value": "genre/other"
},
{
"label": "Other",
"value": "genre/other"
},
{
"label": "Psychological",
"value": "genre/psychological"
},
{
"label": "Reincarnation",
"value": "genre/reincarnation"
},
{
"label": "Romance",
"value": "genre/romance"
},
{
"label": "Romance.smut",
"value": "genre/romance.smut"
},
{
"label": "School life",
"value": "genre/school-life"
},
{
"label": "Sci-fi",
"value": "genre/sci-fi"
},
{
"label": "Seinen",
"value": "genre/seinen"
},
{
"label": "Shoujo",
"value": "genre/shoujo"
},
{
"label": "Shoujo ai",
"value": "genre/shoujo-ai"
},
{
"label": "Shounen",
"value": "genre/shounen"
},
{
"label": "Shounen ai",
"value": "genre/shounen-ai"
},
{
"label": "Slice of life",
"value": "genre/slice-of-life"
},
{
"label": "Smut",
"value": "genre/smut"
},
{
"label": "Sports",
"value": "genre/sports"
},
{
"label": "Supernatural",
"value": "genre/supernatural"
},
{
"label": "System",
"value": "genre/system"
},
{
"label": "Thriller",
"value": "genre/thriller"
},
{
"label": "Tragedy",
"value": "genre/tragedy"
},
{
"label": "Urban",
"value": "genre/urban"
},
{
"label": "Urban life",
"value": "genre/urban-life"
},
{
"label": "Video games",
"value": "genre/video-games"
},
{
"label": "War",
"value": "genre/war"
},
{
"label": "Wuxia",
"value": "genre/wuxia"
},
{
"label": "Xianxia",
"value": "genre/xianxia"
},
{
"label": "Xuanhuan",
"value": "genre/xuanhuan"
},
{
"label": "Yaoi",
"value": "genre/yaoi"
},
{
"label": "Yuri",
"value": "genre/yuri"
}
]
},
"complete": {
"type": "Switch",
"label": "Show Completed Novels Only",
"value": false
}
}
}
@@ -0,0 +1,174 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "most-popular",
"options": [
{
"label": "Hot Novel",
"value": "hot-novel"
},
{
"label": "Completed Novel",
"value": "completed-novel"
},
{
"label": "Most Popular",
"value": "most-popular"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Shounen",
"value": "genre/Shounen"
},
{
"label": "Harem",
"value": "genre/Harem"
},
{
"label": "Comedy",
"value": "genre/Comedy"
},
{
"label": "Martial Arts",
"value": "genre/Martial+Arts"
},
{
"label": "School Life",
"value": "genre/School+Life"
},
{
"label": "Mystery",
"value": "genre/Mystery"
},
{
"label": "Shoujo",
"value": "genre/Shoujo"
},
{
"label": "Romance",
"value": "genre/Romance"
},
{
"label": "Sci-fi",
"value": "genre/Sci-fi"
},
{
"label": "Gender Bender",
"value": "genre/Gender+Bender"
},
{
"label": "Mature",
"value": "genre/Mature"
},
{
"label": "Fantasy",
"value": "genre/Fantasy"
},
{
"label": "Horror",
"value": "genre/Horror"
},
{
"label": "Drama",
"value": "genre/Drama"
},
{
"label": "Tragedy",
"value": "genre/Tragedy"
},
{
"label": "Supernatural",
"value": "genre/Supernatural"
},
{
"label": "Ecchi",
"value": "genre/Ecchi"
},
{
"label": "Xuanhuan",
"value": "genre/Xuanhuan"
},
{
"label": "Adventure",
"value": "genre/Adventure"
},
{
"label": "Action",
"value": "genre/Action"
},
{
"label": "Psychological",
"value": "genre/Psychological"
},
{
"label": "Xianxia",
"value": "genre/Xianxia"
},
{
"label": "Wuxia",
"value": "genre/Wuxia"
},
{
"label": "Historical",
"value": "genre/Historical"
},
{
"label": "Slice of Life",
"value": "genre/Slice+of+Life"
},
{
"label": "Seinen",
"value": "genre/Seinen"
},
{
"label": "Lolicon",
"value": "genre/Lolicon"
},
{
"label": "Adult",
"value": "genre/Adult"
},
{
"label": "Josei",
"value": "genre/Josei"
},
{
"label": "Sports",
"value": "genre/Sports"
},
{
"label": "Smut",
"value": "genre/Smut"
},
{
"label": "Mecha",
"value": "genre/Mecha"
},
{
"label": "Yaoi",
"value": "genre/Yaoi"
},
{
"label": "Shounen Ai",
"value": "genre/Shounen+Ai"
},
{
"label": "History",
"value": "genre/History"
},
{
"label": "Martial",
"value": "genre/Martial"
}
]
}
}
}
@@ -0,0 +1,174 @@
{
"filters": {
"type": {
"type": "Picker",
"label": "Novel Listing",
"value": "novel-list/most-popular-novel",
"options": [
{
"label": "Hot Novel",
"value": "novel-list/hot-novel"
},
{
"label": "Completed Novel",
"value": "novel-list/completed-novel"
},
{
"label": "Most Popular",
"value": "novel-list/most-popular-novel"
}
]
},
"genres": {
"type": "Picker",
"label": "Genre",
"value": "",
"options": [
{
"label": "Action",
"value": "genres/action"
},
{
"label": "Adult",
"value": "genres/adult"
},
{
"label": "Adventure",
"value": "genres/adventure"
},
{
"label": "Comedy",
"value": "genres/comedy"
},
{
"label": "Drama",
"value": "genres/drama"
},
{
"label": "Eastern",
"value": "genres/eastern"
},
{
"label": "Ecchi",
"value": "genres/ecchi"
},
{
"label": "Fantasy",
"value": "genres/fantasy"
},
{
"label": "Game",
"value": "genres/game"
},
{
"label": "Gender Bender",
"value": "genres/gender+bender"
},
{
"label": "Harem",
"value": "genres/harem"
},
{
"label": "Historical",
"value": "genres/historical"
},
{
"label": "Horror",
"value": "genres/horror"
},
{
"label": "Josei",
"value": "genres/josei"
},
{
"label": "Lolicon",
"value": "genres/lolicon"
},
{
"label": "Martial Arts",
"value": "genres/martial+arts"
},
{
"label": "Mature",
"value": "genres/mature"
},
{
"label": "Mecha",
"value": "genres/mecha"
},
{
"label": "Modern Life",
"value": "genres/modern+life"
},
{
"label": "Mystery",
"value": "genres/mystery"
},
{
"label": "Psychological",
"value": "genres/psychological"
},
{
"label": "Reincarnation",
"value": "genres/reincarnation"
},
{
"label": "Romance",
"value": "genres/romance"
},
{
"label": "School life",
"value": "genres/school+life"
},
{
"label": "Sci-fi",
"value": "genres/sci-fi"
},
{
"label": "Seinen",
"value": "genres/seinen"
},
{
"label": "Shoujo",
"value": "genres/shoujo"
},
{
"label": "Shounen",
"value": "genres/shounen"
},
{
"label": "Slice of Life",
"value": "genres/slice+of+life"
},
{
"label": "Smut",
"value": "genres/smut"
},
{
"label": "Sports",
"value": "genres/sports"
},
{
"label": "Supernatural",
"value": "genres/supernatural"
},
{
"label": "System",
"value": "genres/system"
},
{
"label": "Thriller",
"value": "genres/thriller"
},
{
"label": "Tragedy",
"value": "genres/tragedy"
},
{
"label": "Transmigration",
"value": "genres/transmigration"
}
]
}
}
}
@@ -0,0 +1,39 @@
import list from './sources.json' with { type: 'json' };
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const folder = dirname(fileURLToPath(import.meta.url));
export const generateAll = function () {
return list.map(source => {
const exist = existsSync(join(folder, 'filters', source.id + '.json'));
if (exist) {
const filters = readFileSync(
join(folder, 'filters', source.id + '.json'),
);
source.filters = JSON.parse(filters).filters;
}
console.log(
`[readnovelfull] Generating: ${source.id}${' '.repeat(20 - source.id.length)} ${source.filters ? '🔎with filters🔍' : '🚫no filters🚫'}`,
);
return generator(source);
});
};
const generator = function generator(source) {
const readNovelFullTemplate = readFileSync(join(folder, 'template.ts'), {
encoding: 'utf-8',
});
const pluginScript = `
${readNovelFullTemplate}
const plugin = new ReadNovelFullPlugin(${JSON.stringify(source)});
export default plugin;
`.trim();
return {
lang: source.options?.lang || 'English',
filename: source.sourceName,
pluginScript,
};
};
@@ -0,0 +1,225 @@
import * as fs from 'fs';
import * as cheerio from 'cheerio';
import * as path from 'path';
import * as readline from 'readline';
import process from 'process';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function extractValueFromHref(href, baseUrl, name, isGenre = false) {
const fullUrl = new URL(decodeURI(href), baseUrl);
const params = fullUrl.searchParams;
if (isGenre && params.get('type') === 'category_novel' && params.has('id')) {
return params.get('id');
}
const value = params.has('type')
? params.get('type')
: fullUrl.pathname.substring(1) || '';
if (!value) {
console.warn(
`Skipping option for ${name}: Could not determine value from href "${href}"`,
);
}
return value;
}
function getFilters(name, html, baseUrl) {
const $ = cheerio.load(html);
const filters = {
filters: {
'type': {
type: 'Picker',
label: 'Novel Listing',
value: '',
options: [],
},
'genres': {
type: 'Picker',
label: 'Genre',
value: '',
options: [],
},
},
};
const baseSelector = 'div.navbar-collapse li.dropdown';
// --- Type Filters ---
let defaultValue = null;
const typeOptions = [];
$(`${baseSelector}:eq(0) li a`).each((_, el) => {
const $el = $(el);
const title = $el.attr('title');
const label = $el.text().trim();
const href = $el.attr('href');
if (!href || !label || title === 'Latest Release') return;
const value = extractValueFromHref(href, baseUrl, name);
if (value) {
typeOptions.push({ label, value });
if (title === 'Most Popular') {
defaultValue = value;
}
}
});
filters.filters.type.options = typeOptions;
filters.filters.type.value = defaultValue ?? (typeOptions[0]?.value || ''); // Set default: Popular > First > Empty
// --- Genres Filters ---
const genreOptions = [];
$(`${baseSelector}:eq(1) li a`).each((_, el) => {
const $el = $(el);
const label = $el.text().trim();
const href = $el.attr('href');
if (!href || !label) return;
const value = extractValueFromHref(href, baseUrl, name, true);
if (value) {
genreOptions.push({ label, value });
}
});
filters.filters.genres.options = genreOptions; // Genre has no default
// --- Validation and Saving ---
if (
filters.filters.type.options.length === 0 ||
filters.filters.genres.options.length === 0
) {
console.warn(
`🚨Warning for ${name}: Type or Genre options might be incomplete or empty. (${path.join(__dirname, 'filters', name + '.json')})🚨`,
);
}
try {
const filtersDir = path.join(__dirname, 'filters');
if (!fs.existsSync(filtersDir))
fs.mkdirSync(filtersDir, { recursive: true });
fs.writeFileSync(
path.join(filtersDir, name + '.json'),
JSON.stringify(filters, null, 2),
);
console.log(`✅Filters created successfully for ${name}✅`);
} catch (error) {
console.error(
`🚨Error writing filters file for ${name}: ${error.message}🚨`,
);
}
}
async function getFiltersFromURL(name, url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`HTTP error! status: ${response.status}, while fetching ${response.url}`,
);
}
const html = await response.text();
try {
getFilters(name, html, url);
} catch (e) {
console.error('Error while getting filters from', url, e);
}
}
const EREASE_PREV_LINE = '\x1b[1A\r\x1b[2K';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
async function askGetFilter() {
try {
const sources = JSON.parse(
fs.readFileSync(path.join(__dirname, 'sources.json'), 'utf-8'),
);
rl.question(
'Enter the id of the site (same one as in sources.json): ',
async name => {
const baseUrl = await getBaseUrl(name, sources);
rl.question(
EREASE_PREV_LINE +
"Do you want to get the filters from a URL or the html text? (if url doesn't work try html) (url/html): ",
async method => {
if (method.toLowerCase() === 'url') {
if (baseUrl) {
console.log('Getting filters from', baseUrl);
try {
await getFiltersFromURL(name, baseUrl);
} catch (e) {
console.error('Error while getting filters from', baseUrl);
console.log(e.message || e);
}
} else {
console.error(
'Cannot get filters from URL: Base URL is not available.',
);
}
rl.close();
} else {
rl.question(
EREASE_PREV_LINE + 'Enter the absolute path to the HTML file: ',
async filePath => {
try {
const html = fs.readFileSync(filePath, 'utf-8');
if (baseUrl) {
console.log('Using base URL:', baseUrl);
} else {
console.error(
'Cannot get filters from HTML: Base URL is not available.',
);
}
getFilters(name, html, baseUrl);
} catch (e) {
console.error(
'Error reading HTML file or getting filters:',
e.message || e,
);
} finally {
rl.close();
}
},
);
}
},
);
},
);
} catch (e) {
console.error('Error reading sources.json:', e.message || e);
rl.close();
}
}
askGetFilter();
async function getBaseUrl(name, sources) {
const source = sources.find(s => s.id === name);
if (source && source.sourceSite) {
console.log('Using base URL from sources.json:', source.sourceSite);
return source.sourceSite;
} else {
console.warn(
`Source with id "${name}" not found or missing sourceSite in sources.json.`,
);
return new Promise(resolve => {
rl.question(
EREASE_PREV_LINE + 'Enter the base URL for the site: ',
manualBaseUrl => {
resolve(manualBaseUrl);
},
);
});
}
}
export { getFiltersFromURL };
+102
View File
@@ -0,0 +1,102 @@
[
{
"id": "readnovelfull",
"sourceSite": "https://readnovelfull.com/",
"sourceName": "ReadNovelFull",
"options": {
"latestPage": "novel-list/latest-release-novel",
"searchPage": "novel-list/search"
}
},
{
"id": "allnovel",
"sourceSite": "https://allnovel.org/",
"sourceName": "AllNovel",
"options": {
"latestPage": "latest-release-novel",
"searchPage": "search",
"chapterListing": "ajax-chapter-option"
}
},
{
"id": "lightnovelplus",
"sourceSite": "https://lightnovelplus.com/",
"sourceName": "LightNovelPlus",
"options": {
"novelListing": "book/bookclass.html",
"searchPage": "book/search.html",
"chapterListing": "get_chapter_list",
"chapterParam": "bookId",
"latestPage": "last_release",
"pageParam": "page_num",
"langParam": "language",
"urlLangCode": "en"
}
},
{
"id": "boxnovel",
"sourceSite": "https://novlove.com/",
"sourceName": "BoxNovel",
"options": {
"latestPage": "sort/nov-love-daily-update",
"searchPage": "search"
}
},
{
"id": "novelfull",
"sourceSite": "https://novelfull.com/",
"sourceName": "NovelFull",
"options": {
"latestPage": "latest-release-novel",
"searchPage": "search",
"chapterListing": "ajax-chapter-option"
}
},
{
"id": "libread",
"sourceSite": "https://libread.com/",
"sourceName": "Lib Read",
"options": {
"latestPage": "sort/latest-novels",
"searchPage": "search",
"searchKey": "searchkey",
"postSearch": true,
"noAjax": true,
"noPages": ["sort/most-popular"],
"pageAsPath": true
}
},
{
"id": "FWN.com",
"sourceSite": "https://freewebnovel.com/",
"sourceName": "Free Web Novel",
"options": {
"latestPage": "sort/latest-novels",
"searchPage": "search",
"searchKey": "searchkey",
"postSearch": true,
"noAjax": true,
"noPages": ["sort/most-popular"],
"pageAsPath": true
}
},
{
"id": "anf.net",
"sourceSite": "https://novgo.net/",
"sourceName": "AllNovelFull",
"options": {
"latestPage": "latest-release-novel",
"searchPage": "search",
"chapterListing": "ajax-chapter-option"
}
},
{
"id": "novelbin",
"sourceSite": "https://novelbin.com/",
"sourceName": "Novel Bin",
"options": {
"latestPage": "sort/latest",
"searchPage": "search"
}
}
]
+758
View File
@@ -0,0 +1,758 @@
import { Parser } from 'htmlparser2';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { Filters } from '@libs/filterInputs';
type ReadNovelFullOptions = {
lang?: string;
versionIncrements?: number;
latestPage: string;
searchPage: string;
chapterListing?: string;
chapterParam?: string;
pageParam?: string;
novelListing?: string;
typeParam?: string;
genreParam?: string;
genreKey?: string;
langParam?: string;
urlLangCode?: string;
searchKey?: string;
postSearch?: boolean;
noAjax?: boolean;
noPages?: string[];
pageAsPath?: boolean;
};
export type ReadNovelFullMetadata = {
id: string;
sourceSite: string;
sourceName: string;
options: ReadNovelFullOptions;
filters?: any;
};
class ReadNovelFullPlugin implements Plugin.PluginBase {
id: string;
name: string;
icon: string;
site: string;
version: string;
options: ReadNovelFullOptions;
filters?: Filters | undefined;
constructor(metadata: ReadNovelFullMetadata) {
this.id = metadata.id;
this.name = metadata.sourceName;
this.icon = `multisrc/readnovelfull/${metadata.id.toLowerCase()}/icon.png`;
this.site = metadata.sourceSite;
const versionIncrements = metadata.options?.versionIncrements || 0;
this.version = `2.1.${2 + versionIncrements}`;
this.options = metadata.options;
this.filters = metadata.filters;
}
lastSearch: number | null = null;
searchInterval = 3400;
async sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
parseNovels(html: string) {
const novels: Plugin.NovelItem[] = [];
let tempNovel: Partial<Plugin.NovelItem> = {};
let depth: number;
const stateStack: ParsingState[] = [ParsingState.Idle];
const currentState = () => stateStack[stateStack.length - 1];
const pushState = (state: ParsingState) => stateStack.push(state);
const popState = () =>
stateStack.length > 1 ? stateStack.pop() : currentState();
const parser = new Parser({
onopentag: (name, attribs) => {
const state = currentState();
if (
attribs.class?.includes('archive') ||
attribs.class === 'col-content'
) {
pushState(ParsingState.NovelList);
depth = 0;
}
if (
state !== ParsingState.NovelList &&
state !== ParsingState.NovelName
)
return;
switch (name) {
case 'img':
const cover = attribs['data-src'] || attribs.src;
if (cover) {
tempNovel.cover = new URL(cover, this.site).href;
}
break;
case 'h3':
if (state === ParsingState.NovelList) {
pushState(ParsingState.NovelName);
}
break;
case 'a':
if (state === ParsingState.NovelName) {
const href = attribs.href;
if (href) {
tempNovel.path = new URL(href, this.site).pathname.substring(1);
tempNovel.name = attribs.title;
}
}
break;
case 'div':
depth++;
break;
default:
return;
}
},
onclosetag: name => {
const state = currentState();
if (name === 'a' && state === ParsingState.NovelName) {
if (tempNovel.name && tempNovel.path) {
novels.push({ ...tempNovel } as Plugin.NovelItem);
}
tempNovel = {};
popState();
}
if (name === 'div' && state === ParsingState.NovelList) {
depth--;
if (depth < 0) popState();
}
},
});
parser.write(html);
parser.end();
return novels;
}
async popularNovels(
pageNo: number,
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
const {
pageParam = 'page',
novelListing,
typeParam = 'type',
latestPage,
genreParam = 'category_novel',
genreKey = 'id',
langParam,
urlLangCode,
noPages = [],
pageAsPath = false,
} = this.options;
// Skip Pagination for FWN & LR
if (
pageNo !== 1 &&
!showLatestNovels &&
!filters.genres.value.length &&
noPages.length > 0 &&
noPages.includes(filters.type.value)
) {
return [];
}
let url = '';
if (novelListing) {
// URL structure with parameters
const params = new URLSearchParams();
if (showLatestNovels) {
params.append(typeParam, latestPage);
} else if (filters.genres.value.length) {
params.append(typeParam, genreParam);
params.append(genreKey, filters.genres.value);
} else {
params.append(typeParam, filters.type.value);
}
// Add language parameter if specified
if (langParam && urlLangCode) {
params.append(langParam, urlLangCode);
}
params.append(pageParam, pageNo.toString());
url = `${this.site}${novelListing}?${params.toString()}`;
} else {
// URL structure with path segments
const basePage = showLatestNovels
? latestPage
: filters.genres.value.length
? filters.genres.value
: filters.type.value;
if (pageAsPath) {
if (pageNo > 1) {
url = `${this.site}${basePage}/${pageNo.toString()}`;
} else {
url = `${this.site}${basePage}`;
}
} else {
url = `${this.site}${basePage}?${pageParam}=${pageNo.toString()}`;
}
}
const result = await fetchApi(url);
if (!result.ok) {
throw new Error(
`Could not reach site (${result.status}: ${result.statusText}) try to open in webview.`,
);
}
const html = await result.text();
return this.parseNovels(html);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const result = await fetchApi(url);
const body = await result.text();
const novel: Partial<Plugin.SourceNovel> = {
path: novelPath,
chapters: [],
};
const summaryParts: string[] = [];
const statusParts: string[] = [];
const authorParts: string[] = [];
const genreArray: string[] = [];
const infoParts: string[] = [];
const chapters: Plugin.ChapterItem[] = [];
let novelId: string | null = null;
let tempChapter: Partial<Plugin.ChapterItem> = {};
let i = 0;
let depth: number;
const stateStack: ParsingState[] = [ParsingState.Idle];
const currentState = () => stateStack[stateStack.length - 1];
const pushState = (state: ParsingState) => stateStack.push(state);
const popState = () =>
stateStack.length > 1 ? stateStack.pop() : currentState();
const parser = new Parser({
onopentag: (name, attribs) => {
const state = currentState();
switch (name) {
case 'div':
switch (attribs.class) {
case 'books':
case 'm-imgtxt':
pushState(ParsingState.Cover);
return;
case 'inner':
case 'desc-text':
if (state === ParsingState.Cover) popState();
pushState(ParsingState.Summary);
break;
case 'info':
pushState(ParsingState.Info);
depth = 0;
break;
}
if (!this.options.noAjax && attribs.id === 'rating') {
novelId = attribs['data-novel-id'];
}
if (state === ParsingState.Info) depth++;
break;
case 'img':
if (state === ParsingState.Cover) {
const cover =
attribs.src ?? attribs['data-cfsrc'] ?? attribs['data-src'];
const name = attribs.title;
if (cover) {
novel.cover = new URL(cover, this.site).href;
}
if (name) {
novel.name = name;
} else {
popState();
}
}
break;
case 'h3':
if (state === ParsingState.Cover) {
pushState(ParsingState.NovelName);
}
break;
case 'span':
if (state === ParsingState.Cover && attribs.title) {
const newState = {
'Genre': ParsingState.Genres,
'Author': ParsingState.Author,
'Status': ParsingState.Status,
}[attribs.title];
if (newState) pushState(newState);
}
break;
case 'br':
if (state === ParsingState.Summary) {
summaryParts.push('\n');
}
break;
case 'ul':
if (attribs.class?.includes('info-meta')) {
pushState(ParsingState.Info);
}
if (this.options.noAjax && attribs.id === 'idData') {
pushState(ParsingState.ChapterList);
}
break;
case 'a':
if (state === ParsingState.ChapterList) {
i++;
const href = attribs.href;
pushState(ParsingState.Chapter);
tempChapter.name = attribs.title || `Chapter ${i}`;
tempChapter.releaseTime = null;
tempChapter.chapterNumber = i;
tempChapter.path =
href?.substring(1) ||
novelPath.replace('.html', `/chapter-${i}.html`);
}
break;
}
},
ontext: data => {
const text = data.trim();
if (!text) return;
switch (currentState()) {
case ParsingState.NovelName:
novel.name = (novel.name || '') + text;
break;
case ParsingState.Summary:
summaryParts.push(data);
break;
case ParsingState.Info:
infoParts.push(text);
break;
case ParsingState.Genres:
genreArray.push(data);
break;
case ParsingState.Author:
authorParts.push(data);
break;
case ParsingState.Status:
statusParts.push(text);
break;
}
},
onclosetag: name => {
const state = currentState();
switch (name) {
case 'div':
switch (state) {
case ParsingState.Info:
depth--;
infoParts.push('\n');
if (depth < 0) {
popState();
}
break;
case ParsingState.Genres:
case ParsingState.Author:
case ParsingState.Status:
case ParsingState.Summary:
popState();
break;
}
break;
case 'h3':
if (state === ParsingState.NovelName) {
popState();
}
break;
case 'a':
if (state === ParsingState.Chapter) {
if (tempChapter.name && tempChapter.path) {
chapters.push({ ...tempChapter } as Plugin.ChapterItem);
}
tempChapter = {};
popState();
}
break;
case 'li':
if (state === ParsingState.Info) {
infoParts.push('\n');
}
break;
case 'ul':
switch (state) {
case ParsingState.Info:
case ParsingState.ChapterList:
popState();
break;
}
break;
default:
return;
}
},
onend: () => {
if (infoParts.length) {
infoParts
.join('')
.split('\n')
.map(line => line.trim())
.filter(line => line.includes(':'))
.forEach(line => {
const parts = line.split(':');
const detailName = parts[0].trim().toLowerCase();
const detail = parts[1]
.split(',')
.map(g => g.trim())
.join(', ');
switch (detailName) {
case 'author':
novel.author = detail;
break;
case 'genre':
novel.genres = detail;
break;
case 'status':
const map: Record<string, string> = {
ongoing: NovelStatus.Ongoing,
hiatus: NovelStatus.OnHiatus,
dropped: NovelStatus.Cancelled,
cancelled: NovelStatus.Cancelled,
completed: NovelStatus.Completed,
};
novel.status =
map[detail.toLowerCase()] ?? NovelStatus.Unknown;
break;
default:
return;
}
});
if (!novelId) {
const idMatch = novelPath.match(/\d+/);
novelId = idMatch ? idMatch[0] : null;
}
} else {
novel.genres = genreArray.join('').trim();
novel.author = authorParts.join('').trim();
novel.status = statusParts
.join('')
.toLowerCase()
.replace(/\b\w/g, char => char.toUpperCase());
}
novel.summary = summaryParts.join('\n\n').trim();
},
});
parser.write(body);
parser.end();
if (this.options.noAjax && chapters.length > 0) {
novel.chapters = chapters;
} else if (novelId !== null) {
const chapterListing =
this.options.chapterListing || 'ajax/chapter-archive';
const ajaxParam = this.options.chapterParam || 'novelId';
const params = new URLSearchParams({ [ajaxParam]: novelId });
const chaptersUrl = `${this.site}${chapterListing}?${params.toString()}`;
const ajaxResult = await fetchApi(chaptersUrl);
if (!ajaxResult.ok) {
console.error(`Failed to fetch chapters: ${ajaxResult.status}`);
novel.chapters = [];
} else {
const ajaxBody = await ajaxResult.text();
const ajaxChapters: Plugin.ChapterItem[] = [];
let tempAjaxChapter: Partial<Plugin.ChapterItem> = {};
const ajaxParser = new Parser({
onopentag: (name, attribs) => {
let chapterHref: string | undefined;
let initialName: string | undefined;
if (name === 'a' && attribs.href) {
chapterHref = attribs.href;
initialName = attribs.title || '';
pushState(ParsingState.Chapter);
} else if (name === 'option' && attribs.value) {
chapterHref = attribs.value;
initialName = '';
pushState(ParsingState.Chapter);
}
if (chapterHref !== undefined) {
const href = new URL(chapterHref, this.site);
tempAjaxChapter.path = href.pathname.substring(1);
tempAjaxChapter.name = initialName;
}
},
ontext: data => {
const text = data.trim();
if (
currentState() === ParsingState.Chapter &&
!tempAjaxChapter.name &&
text
) {
tempAjaxChapter.name += text;
}
},
onclosetag: name => {
if (
(name === 'a' || name === 'option') &&
currentState() === ParsingState.Chapter
) {
if (tempAjaxChapter.name && tempAjaxChapter.path) {
tempAjaxChapter.name = tempAjaxChapter.name.trim();
tempAjaxChapter.releaseTime = null;
ajaxChapters.push({
...tempAjaxChapter,
} as Plugin.ChapterItem);
}
tempAjaxChapter = {};
popState();
}
},
});
ajaxParser.write(ajaxBody);
ajaxParser.end();
novel.chapters = ajaxChapters;
}
}
return novel as Plugin.SourceNovel;
}
async parseChapter(chapterPath: string): Promise<string> {
const response = await fetchApi(this.site + chapterPath);
const html = await response.text();
let depth: number;
let depthHide: number;
const chapterHtml: string[] = [];
let skipClosingTag = false;
let currentTagToSkip = '';
const stateStack: ParsingState[] = [ParsingState.Idle];
const currentState = () => stateStack[stateStack.length - 1];
const pushState = (state: ParsingState) => stateStack.push(state);
const popState = () =>
stateStack.length > 1 ? stateStack.pop() : currentState();
type EscapeChar = '&' | '<' | '>' | '"' | "'" | ' ';
const escapeRegex = /[&<>"' ]/g;
const escapeMap: Record<EscapeChar, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
' ': '&nbsp;',
};
const escapeHtml = (text: string): string =>
text.replace(escapeRegex, char => escapeMap[char as EscapeChar]);
const parser = new Parser({
onopentag(name, attribs) {
const state = currentState();
const attrib = attribs.class?.trim();
switch (state) {
case ParsingState.Idle:
if (
attrib === 'txt' ||
attribs.id === 'chr-content' ||
attribs.id === 'chapter-content'
) {
pushState(ParsingState.Chapter);
depth = 0;
}
break;
case ParsingState.Chapter:
if (name === 'sub') {
pushState(ParsingState.Hidden);
} else if (name === 'div') {
depth++;
if (
attrib?.includes('unlock-buttons') ||
attrib?.includes('ads')
) {
pushState(ParsingState.Hidden);
depthHide = 0;
}
}
break;
case ParsingState.Hidden:
if (name === 'sub') {
// Allow nesting of hidden states if a sub is inside a div
pushState(ParsingState.Hidden);
} else if (name === 'div') {
depthHide++;
}
break;
default:
return;
}
if (currentState() === ParsingState.Chapter) {
const attrKeys = Object.keys(attribs);
if (attrKeys.length === 0) {
chapterHtml.push(`<${name}>`);
} else if (attrKeys.every(key => attribs[key].trim() === '')) {
// Handle tags with empty attributes as text content
// eg: novel/rising-up-from-a-nobleman-to-intergalactic-warlord/chapter-184
skipClosingTag = true;
currentTagToSkip = name;
const uppercaseName = name.replace(/\b\w/g, char =>
char.toUpperCase(),
);
chapterHtml.push(
escapeHtml(`<${uppercaseName} ${attrKeys.join(' ')}>`),
);
} else {
// Normal tag with attributes
const attrString = attrKeys
.map(key => ` ${key}="${attribs[key].replace(/"/g, '&quot;')}"`)
.join('');
chapterHtml.push(`<${name}${attrString}>`);
}
}
},
ontext(text) {
if (currentState() === ParsingState.Chapter) {
chapterHtml.push(escapeHtml(text));
}
},
onclosetag(name) {
const state = currentState();
if (state === ParsingState.Hidden) {
if (name === 'sub') {
popState();
} else if (name === 'div') {
depthHide--;
if (depthHide < 0) {
popState();
depth--;
}
}
}
if (state !== ParsingState.Chapter) {
return;
}
if (!parser['isVoidElement'](name)) {
if (skipClosingTag && name === currentTagToSkip) {
skipClosingTag = false;
currentTagToSkip = '';
} else {
chapterHtml.push(`</${name}>`);
}
}
if (name === 'div') {
depth--;
if (depth < 0) {
pushState(ParsingState.Stopped);
}
}
},
});
parser.write(html);
parser.end();
return chapterHtml.join('');
}
async searchNovels(
searchTerm: string,
page: number,
): Promise<Plugin.NovelItem[]> {
const now = Date.now();
if (this.lastSearch && now - this.lastSearch <= this.searchInterval) {
await this.sleep(this.searchInterval);
}
const {
pageParam = 'page',
searchKey = 'keyword',
postSearch,
langParam,
urlLangCode,
searchPage,
} = this.options;
const params = new URLSearchParams({
[searchKey]: searchTerm,
...(langParam && urlLangCode && { [langParam]: urlLangCode }),
...(!postSearch && { [pageParam]: page.toString() }),
});
const url = `${this.site}${searchPage}${!postSearch ? `?${params.toString()}` : ''}`;
const fetchOptions: RequestInit | undefined = postSearch
? {
method: 'POST',
body: params.toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
: undefined;
const result = await fetchApi(url, fetchOptions);
this.lastSearch = Date.now();
if (!result.ok) {
throw new Error(
`Could not reach site ('${result.status}') try to open in webview.`,
);
}
const html = await result.text();
// Check for alert error messages, ported over from cheerio TODO: confirm behaviour
const alertText = html.match(/alert\((.*?)\)/)?.[1] || '';
if (alertText) throw new Error(alertText);
return this.parseNovels(html);
}
}
enum ParsingState {
Idle,
Info,
Cover,
Author,
Genres,
Status,
Hidden,
Summary,
Stopped,
Chapter,
ChapterList,
NovelName,
NovelList,
}