-- {"ver":"2.9.0","author":"TechnoJo4","dep":["url"]} local encode = Require("url").encode local text = function(v) return v:text() end local settings = {} local defaults = { latestNovelSel = "div.col-12.col-md-6", searchNovelSel = "div.c-tabs-item__content", novelListingURLPath = "novel", -- Certain sites like TeamXNovel do not use [novelListingURLPath] and instead use a suffix to the query to declare what is expected. novelListingURLSuffix = "", novelPageTitleSel = "div.post-title", shrinkURLNovel = "novel", searchHasOper = false, -- is AND/OR operation selector present? hasCloudFlare = false, hasSearch = true, chapterType = ChapterType.HTML, chaptersOrderReversed = true, -- If chaptersScriptLoaded is true, then a ajax request has to be made to get the chapter list. -- Otherwise the chapter list is already loaded when loading the novel overview. chaptersScriptLoaded = true, chaptersListSelector= "li.wp-manga-chapter", -- If ajaxUsesFormData is true, then a POST request will be send to baseURL/ajaxFormDataUrl. -- Otherwise to baseURL/shrinkURLNovel/novelurl/ajaxSeriesUrl . ajaxUsesFormData = false, ajaxFormDataSel= "a.wp-manga-action-button", ajaxFormDataAttr = "data-post", ajaxFormDataUrl = "/wp-admin/admin-ajax.php", ajaxSeriesUrl = "ajax/chapters/", --- Some sites require custom CSS to exist, such as RTL support customStyle = "", } local ORDER_BY_FILTER_EXT = { "Relevance", "Latest", "A-Z", "Rating", "Trending", "Most Views", "New" } local ORDER_BY_FILTER_KEY = 2 local AUTHOR_FILTER_KEY = 3 local ARTIST_FILTER_KEY = 4 local RELEASE_FILTER_KEY = 5 local STATUS_FILTER_KEY_COMPLETED = 6 local STATUS_FILTER_KEY_ONGOING = 7 local STATUS_FILTER_KEY_CANCELED = 8 local STATUS_FILTER_KEY_ON_HOLD = 9 function defaults:latest(data) return self.parse(GETDocument(self.baseURL .. "/" .. self.novelListingURLPath .. "/page/" .. data[PAGE] .. "/?m_orderby=latest" .. self.novelListingURLSuffix)) end ---@param tbl table ---@return string function defaults:createSearchString(tbl) local query = tbl[QUERY] local page = tbl[PAGE] local orderBy = tbl[ORDER_BY_FILTER_KEY] local author = tbl[AUTHOR_FILTER_KEY] local artist = tbl[ARTIST_FILTER_KEY] local release = tbl[RELEASE_FILTER_KEY] local url = self.baseURL .. "/page/" .. page .. "/?s=" .. encode(query) .. "&post_type=wp-manga" .. "&author=" .. encode(author) .. "&artist=" .. encode(artist) .. "&release=" .. encode(release) if orderBy ~= nil then url = url .. "&m_orderby=" .. ({ [0] = "relevance", [1] = "latest", [2] = "alphabet", [3] = "rating", [4] = "trending", [5] = "views", [6] = "new-manga" })[orderBy] end if tbl[STATUS_FILTER_KEY_COMPLETED] then url = url .. "&status[]=end" end if tbl[STATUS_FILTER_KEY_ONGOING] then url = url .. "&status[]=on-going" end if tbl[STATUS_FILTER_KEY_CANCELED] then url = url .. "&status[]=canceled" end if tbl[STATUS_FILTER_KEY_ON_HOLD] then url = url .. "&status[]=on-hold" end for key, value in pairs(self.genres_map) do if tbl[key] then url = url .. "&genre[]=" .. value end end if self.searchHasOper then url = url .. "&op=" .. (tbl[self.searchOperId] and "0" or "1") end return self.appendToSearchURL(url, tbl) end ---@param str string ---@param tbl table ---@return string function defaults:appendToSearchURL(str, tbl) return str end ---@param tbl table ---@return table function defaults:appendToSearchFilters(tbl) return tbl end function defaults:search(data) local url = self.createSearchString(data) return self.parse(GETDocument(url), true) end ---@param url string ---@return string function defaults:getPassage(url) local htmlElement = GETDocument(self.expandURL(url)):selectFirst("div.c-blog-post") local title = htmlElement:selectFirst("ol.breadcrumb li.active"):text() htmlElement = htmlElement:selectFirst("div.text-left") -- Chapter title inserted before chapter text htmlElement:prepend("

" .. title .. "

"); -- Remove/modify unwanted HTML elements to get a clean webpage. htmlElement:select("div.lnbad-tag"):remove() -- LightNovelBastion text size htmlElement:select("i.icon.j_open_para_comment.j_para_comment_count"):remove() -- BoxNovel, VipNovel numbers return pageOfElem(htmlElement, true, self.customStyle) end ---@param image_element Element An img element of which the biggest image shall be selected. ---@return string A link to the biggest image of the image_element. local function img_src(image_element) -- Different extensions have the image(s) saved in different attributes. Not even uniformly for one extension. -- Partially this comes down to script loading the pictures. Therefore, scour for a picture in the default HTML page. -- Check data-srcset: local srcset = image_element:attr("data-srcset") if srcset ~= "" then -- Get the largest image. local max_size, max_url = 0, "" for url, size in srcset:gmatch("(http.-) (%d+)w") do if tonumber(size) > max_size then max_size = tonumber(size) max_url = url end end return max_url end -- Check data-src: srcset = image_element:attr("data-src") if srcset ~= "" then return srcset end -- Check data-lazy-src: srcset = image_element:attr("data-lazy-src") if srcset ~= "" then return srcset end -- Default to src (the most likely place to be loaded via script): return image_element:attr("src") end ---@param document Document The page containing novel information ---@return string the novel description function defaults:parseNovelDescription(document) local summaryContent = document:selectFirst("div.summary__content") if summaryContent then return table.concat(map(document:selectFirst("div.summary__content"):select("p"), text), "\n") end local mangaExcerpt = document:selectFirst("div.manga-excerpt") if mangaExcerpt then return mangaExcerpt:text() end return "" end ---@param url string ---@param loadChapters boolean ---@return NovelInfo function defaults:parseNovel(url, loadChapters) local doc = GETDocument(self.expandURL(url)) local content = doc:selectFirst("div.post-content") -- Removing HOT or NEW from title. local titleElement = doc:selectFirst(self.novelPageTitleSel) titleElement:select("span"):remove() -- Temporarily saves a Jsoup selection for repeated use. Initial value used for status. local selectedContent = doc:selectFirst("div.post-status"):select("div.post-content_item") -- For some that doesn't have thumbnail local imgUrl = doc:selectFirst("div.summary_image") if imgUrl then imgUrl = img_src(imgUrl:selectFirst("img.img-responsive")) end local info = NovelInfo { description = self.parseNovelDescription(doc), title = titleElement:text(), imageURL = imgUrl, status = ({ OnGoing = NovelStatus.PUBLISHING, Completed = NovelStatus.COMPLETED, Canceled = NovelStatus.PAUSED, ["On Hold"] = NovelStatus.PAUSED, Ongoing = NovelStatus.PUBLISHING -- Never spotted, but better safe than sorry. -- If there is a 'Release' content item then it comes before the 'Status'. -- Therefore, select last content item. })[selectedContent:get(selectedContent:size()-1):select("div.summary-content"):text()] } -- Not every Novel has an guaranteed author, artist or genres (looking at you NovelTrench). selectedContent = content:selectFirst("div.author-content") if selectedContent ~= nil then info:setAuthors( map(selectedContent:select("a"), text) ) end selectedContent = content:selectFirst("div.artist-content") if selectedContent ~= nil then info:setArtists( map(selectedContent:select("a"), text) ) end selectedContent = content:selectFirst("div.genres-content") if selectedContent ~= nil then info:setGenres( map(selectedContent:select("a"), text) ) end -- Chapters -- Overrides `doc` if self.chaptersScriptLoaded is true. if loadChapters then if self.chaptersScriptLoaded then if self.ajaxUsesFormData then -- Old method. local button = doc:selectFirst(self.ajaxFormDataSel) local id = button:attr(self.ajaxFormDataAttr) doc = RequestDocument( POST(self.baseURL .. self.ajaxFormDataUrl, nil, FormBodyBuilder() :add("action", "manga_get_chapters") :add("manga", id):build()) ) else -- Used by BoxNovel, Foxaholic, NovelTrench, LightNovelHeaven, VipNovel and WoopRead. doc = RequestDocument( POST(self.baseURL .. "/" .. self.shrinkURLNovel .. "/" .. url .. self.ajaxSeriesUrl, nil, nil) ) end end local chapterList = doc:select(self.chaptersListSelector) local chapterOrder = -1 if self.chaptersOrderReversed then chapterOrder = chapterList:size() end local novelList = AsList(map(chapterList, function(v) if self.chaptersOrderReversed then chapterOrder = chapterOrder - 1 else chapterOrder = chapterOrder + 1 end return NovelChapter{ title = v:selectFirst("a"):text(), link = self.shrinkURL(v:selectFirst("a"):attr("href")), release = v:selectFirst("span.chapter-release-date"):text(), order = chapterOrder } end)) if self.chaptersOrderReversed then Reverse(novelList) end info:setChapters(novelList) end return info end ---@param doc Document ---@param search boolean function defaults:parse(doc, search) return map(doc:select(search and self.searchNovelSel or self.latestNovelSel), function(v) local novel = Novel() local data = v:selectFirst("a") novel:setLink(self.shrinkURL(data:attr("href"))) local tit = data:attr("title") if tit == "" then tit = data:text() end novel:setTitle(tit) local e = data:selectFirst("img") if e then novel:setImageURL(img_src(e)) end return novel end) end function defaults:expandURL(url) return self.baseURL .. "/" .. self.shrinkURLNovel .. "/" .. url end function defaults:shrinkURL(url) return url:gsub("https?://.-/" .. self.shrinkURLNovel .. "/", "") end return function(baseURL, _self) _self = setmetatable(_self or {}, { __index = function(_, k) local d = defaults[k] return (type(d) == "function" and wrap(_self, d) or d) end }) _self.genres_map = {} local keyID = 100 local filters = { DropdownFilter(ORDER_BY_FILTER_KEY, "Order by", ORDER_BY_FILTER_EXT), TextFilter(AUTHOR_FILTER_KEY, "Author"), TextFilter(ARTIST_FILTER_KEY, "Artist"), TextFilter(RELEASE_FILTER_KEY, "Year of Release"), FilterGroup("Status", { CheckboxFilter(STATUS_FILTER_KEY_COMPLETED, "Completed"), CheckboxFilter(STATUS_FILTER_KEY_ONGOING, "Ongoing"), CheckboxFilter(STATUS_FILTER_KEY_CANCELED, "Canceled"), CheckboxFilter(STATUS_FILTER_KEY_ON_HOLD, "On Hold") }), FilterGroup("Genres", map(_self.genres, function(v, k) keyID = keyID + 1 _self.genres_map[keyID] = k or v:lower():gsub(" ", "-") return CheckboxFilter(keyID, v) end)) -- 6 } _self["isSearchIncrementing"] = true if _self.searchHasOper then keyID = keyID + 1 _self.searchOperId = keyID filters[#filters + 1] = DropdownFilter(keyID, "Genres Condition", { "OR (any of selected)", "AND (all selected)" }) end filters = _self.appendToSearchFilters(filters) _self["searchFilters"] = filters _self["baseURL"] = baseURL _self["listings"] = { Listing("Default", true, _self.latest) } _self["updateSetting"] = function(id, value) settings[id] = value end return _self end