-- {"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