fix: New KDTnovel Domain and Format (#1983)
This commit is contained in:
@@ -1,377 +0,0 @@
|
||||
import { load as parseHTML } from 'cheerio';
|
||||
import { fetchApi } from '@libs/fetch';
|
||||
import { Plugin } from '@/types/plugin';
|
||||
import { defaultCover } from '@libs/defaultCover';
|
||||
import { NovelStatus } from '@libs/novelStatus';
|
||||
|
||||
class KDTNovels implements Plugin.PluginBase {
|
||||
id = 'kdtnovels';
|
||||
name = 'KDT Novels';
|
||||
version = '1.0.0';
|
||||
icon = 'src/en/kdtnovels/icon.png';
|
||||
site = 'https://kdtnovels.com/';
|
||||
|
||||
/**
|
||||
* Parse novel items from HTML using common selectors
|
||||
*/
|
||||
private parseNovelItems(html: string): Plugin.NovelItem[] {
|
||||
const $ = parseHTML(html);
|
||||
const novels: Plugin.NovelItem[] = [];
|
||||
|
||||
$('div.c-tabs-item__content').each((_, element) => {
|
||||
const $element = $(element);
|
||||
|
||||
// Extract cover image
|
||||
const coverImg = $element.find('div.tab-thumb img').first();
|
||||
const cover =
|
||||
coverImg.attr('data-src') || coverImg.attr('src') || defaultCover;
|
||||
|
||||
// Extract title and URL
|
||||
const titleLink = $element.find('div.post-title > h3 > a').first();
|
||||
const name = titleLink.text().trim();
|
||||
const href = titleLink.attr('href');
|
||||
|
||||
// Only add if we have required data
|
||||
if (name && href) {
|
||||
// Convert full URL to relative path, remove leading and trailing slashes
|
||||
const path = href.replace(this.site, '').replace(/^\/+|\/+$/g, '');
|
||||
|
||||
novels.push({
|
||||
name,
|
||||
path,
|
||||
cover: cover || defaultCover,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return novels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse status text and map it to NovelStatus enum
|
||||
*/
|
||||
private parseNovelStatus(statusText: string): string {
|
||||
if (!statusText) {
|
||||
return NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
const normalizedStatus = statusText.toLowerCase().trim();
|
||||
|
||||
// Map common status patterns to NovelStatus enum values
|
||||
if (
|
||||
normalizedStatus.includes('ongoing') ||
|
||||
normalizedStatus.includes('on going')
|
||||
) {
|
||||
return NovelStatus.Ongoing;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('completed') ||
|
||||
normalizedStatus.includes('complete')
|
||||
) {
|
||||
return NovelStatus.Completed;
|
||||
}
|
||||
|
||||
if (normalizedStatus.includes('licensed')) {
|
||||
return NovelStatus.Licensed;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('finished') ||
|
||||
normalizedStatus.includes('publishing finished')
|
||||
) {
|
||||
return NovelStatus.PublishingFinished;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('cancelled') ||
|
||||
normalizedStatus.includes('canceled')
|
||||
) {
|
||||
return NovelStatus.Cancelled;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('hiatus') ||
|
||||
normalizedStatus.includes('on hiatus')
|
||||
) {
|
||||
return NovelStatus.OnHiatus;
|
||||
}
|
||||
|
||||
// If no pattern matches, return the original text or Unknown
|
||||
return statusText || NovelStatus.Unknown;
|
||||
}
|
||||
|
||||
async popularNovels(
|
||||
pageNo: number,
|
||||
options: Plugin.PopularNovelsOptions,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// Construct URL based on whether we want latest or popular novels
|
||||
const orderBy = options.showLatestNovels ? 'latest' : 'views';
|
||||
const url = `${this.site}/page/${pageNo}/?s&post_type=wp-manga&m_orderby=${orderBy}`;
|
||||
|
||||
try {
|
||||
// Fetch the search results page
|
||||
const response = await fetchApi(url);
|
||||
const html = await response.text();
|
||||
|
||||
// Use common parsing method
|
||||
return this.parseNovelItems(html);
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular novels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async searchNovels(
|
||||
searchTerm: string,
|
||||
pageNo: number,
|
||||
): Promise<Plugin.NovelItem[]> {
|
||||
// Construct search URL with proper query parameters
|
||||
const url = `${this.site}/page/${pageNo}/?s=${encodeURIComponent(searchTerm)}&post_type=wp-manga&op&author&artist&release&adult`;
|
||||
|
||||
try {
|
||||
// Fetch the search results page
|
||||
const response = await fetchApi(url);
|
||||
const html = await response.text();
|
||||
|
||||
// Use common parsing method
|
||||
const novels = this.parseNovelItems(html);
|
||||
|
||||
// Handle empty search results gracefully
|
||||
if (novels.length === 0) {
|
||||
console.log(
|
||||
`No search results found for term: "${searchTerm}" on page ${pageNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
return novels;
|
||||
} catch (error) {
|
||||
console.error('Error searching novels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||
const novelUrl = this.site + novelPath;
|
||||
|
||||
try {
|
||||
// Fetch the novel details page
|
||||
const response = await fetchApi(novelUrl);
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML with Cheerio
|
||||
const $ = parseHTML(html);
|
||||
|
||||
const novel: Plugin.SourceNovel = {
|
||||
name: '',
|
||||
path: novelPath,
|
||||
cover: defaultCover,
|
||||
author: '',
|
||||
artist: '',
|
||||
genres: '',
|
||||
summary: '',
|
||||
status: '',
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
// Extract novel metadata using provided CSS selectors
|
||||
|
||||
// Extract novel title
|
||||
const titleElement = $('.manga-title').first();
|
||||
novel.name = titleElement.text().trim() || 'Unknown Title';
|
||||
|
||||
// Extract cover image
|
||||
const coverElement = $('div.summary_image img').first();
|
||||
const coverSrc =
|
||||
coverElement.attr('data-src') || coverElement.attr('src');
|
||||
novel.cover = coverSrc || defaultCover;
|
||||
|
||||
// Extract genres
|
||||
const genreElements = $('div.genres-content a');
|
||||
const genres: string[] = [];
|
||||
genreElements.each((_, element) => {
|
||||
const genre = $(element).text().trim();
|
||||
if (genre) {
|
||||
genres.push(genre);
|
||||
}
|
||||
});
|
||||
novel.genres = genres.join(',');
|
||||
|
||||
// Extract summary/synopsis
|
||||
const summaryElements = $('div.manga-excerpt p');
|
||||
const summaryParts: string[] = [];
|
||||
summaryElements.each((_, element) => {
|
||||
const text = $(element).text().trim();
|
||||
if (text) {
|
||||
summaryParts.push(text);
|
||||
}
|
||||
});
|
||||
novel.summary = summaryParts.join('\n\n');
|
||||
|
||||
// Extract status
|
||||
const statusElement = $('div.manga-status span:nth-child(2)').first();
|
||||
const rawStatus = statusElement.text().trim();
|
||||
novel.status = this.parseNovelStatus(rawStatus);
|
||||
|
||||
// Extract chapter list - chapters are loaded dynamically via AJAX
|
||||
let chapters: Plugin.ChapterItem[] = [];
|
||||
|
||||
try {
|
||||
// Construct AJAX URL for chapter list
|
||||
const ajaxUrl = `${novelUrl}/ajax/chapters/?t=1`;
|
||||
|
||||
// Make POST request to get chapter list HTML fragment
|
||||
const chapterResponse = await fetchApi(ajaxUrl, {
|
||||
method: 'POST',
|
||||
});
|
||||
const chapterHtml = await chapterResponse.text();
|
||||
|
||||
// Parse the HTML fragment
|
||||
const $chapters = parseHTML(chapterHtml);
|
||||
|
||||
// Extract chapters using existing parsing logic
|
||||
const chapterElements = $chapters('li.free-chap');
|
||||
|
||||
chapterElements.each((index, element) => {
|
||||
const $element = $chapters(element);
|
||||
|
||||
// Extract chapter link and name
|
||||
const chapterLink = $element.find('a').first();
|
||||
const chapterName = chapterLink.text().trim();
|
||||
const chapterHref = chapterLink.attr('href');
|
||||
|
||||
// Extract release date
|
||||
const releaseDateElement = $element
|
||||
.find('span.chapter-release-date')
|
||||
.first();
|
||||
const releaseTime = releaseDateElement.text().trim() || null;
|
||||
|
||||
// Extract chapter number from title (opportunistic parsing)
|
||||
let chapterNumber = chapterElements.length - index; // Default fallback (reverse order)
|
||||
|
||||
// Try multiple patterns for chapter number extraction (supporting decimals)
|
||||
let chapterNumberMatch = chapterName.match(/Ch\s*(\d+(?:\.\d+)?)/i);
|
||||
if (!chapterNumberMatch) {
|
||||
chapterNumberMatch = chapterName.match(/c(\d+(?:\.\d+)?)/i);
|
||||
}
|
||||
if (chapterNumberMatch) {
|
||||
chapterNumber = parseFloat(chapterNumberMatch[1]);
|
||||
}
|
||||
|
||||
if (chapterName && chapterHref) {
|
||||
// Extract just the pathname from the URL, removing leading and trailing slashes
|
||||
let chapterPath: string;
|
||||
if (chapterHref.startsWith('http')) {
|
||||
const url = new URL(chapterHref);
|
||||
chapterPath = url.pathname.replace(/^\/+|\/+$/g, '');
|
||||
} else {
|
||||
chapterPath = chapterHref.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
name: chapterName,
|
||||
path: chapterPath,
|
||||
releaseTime: releaseTime,
|
||||
chapterNumber: chapterNumber,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reverse the chapters array since they come in reverse order (latest first)
|
||||
chapters.reverse();
|
||||
} catch (chapterError) {
|
||||
console.error('Error fetching chapter list:', chapterError);
|
||||
// Fall back to empty chapters array if AJAX request fails
|
||||
chapters = [];
|
||||
}
|
||||
|
||||
novel.chapters = chapters;
|
||||
|
||||
return novel;
|
||||
} catch (error) {
|
||||
console.error('Error parsing novel:', error);
|
||||
// Return basic novel object with minimal data on error
|
||||
return {
|
||||
name: 'Error loading novel',
|
||||
path: novelPath,
|
||||
cover: defaultCover,
|
||||
chapters: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async parseChapter(chapterPath: string): Promise<string> {
|
||||
const chapterUrl = this.site + chapterPath;
|
||||
|
||||
try {
|
||||
// Implement HTTP request to fetch chapter page
|
||||
const response = await fetchApi(chapterUrl);
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML with Cheerio
|
||||
const $ = parseHTML(html);
|
||||
|
||||
// Identify main chapter content container using CSS selectors
|
||||
const contentContainer = $('div.reading-content').first();
|
||||
|
||||
if (contentContainer.length === 0) {
|
||||
console.warn('Chapter content container not found');
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove hidden input elements used for tracking (infinite scrolling)
|
||||
contentContainer.find('input[type="hidden"]').remove();
|
||||
|
||||
// Handle embedded images - ensure they have proper attributes
|
||||
contentContainer.find('img').each((_, img) => {
|
||||
const $img = $(img);
|
||||
|
||||
// Handle lazy-loaded images by moving data-src to src if needed
|
||||
const dataSrc = $img.attr('data-src');
|
||||
if (dataSrc && !$img.attr('src')) {
|
||||
$img.attr('src', dataSrc);
|
||||
}
|
||||
|
||||
// Ensure images have alt text for accessibility
|
||||
if (!$img.attr('alt')) {
|
||||
$img.attr('alt', 'Chapter image');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle special formatting elements - preserve paragraph structure
|
||||
// Ensure proper spacing between paragraphs and other block elements
|
||||
contentContainer.find('p, div, br').each((_, element) => {
|
||||
const $element = $(element);
|
||||
|
||||
// Ensure paragraphs have proper spacing
|
||||
if (element.tagName?.toLowerCase() === 'p' && $element.text().trim()) {
|
||||
// Paragraph already has proper HTML structure
|
||||
}
|
||||
|
||||
// Handle div elements that might contain text
|
||||
if (
|
||||
element.tagName?.toLowerCase() === 'div' &&
|
||||
$element.text().trim()
|
||||
) {
|
||||
// Preserve div structure for special formatting
|
||||
}
|
||||
});
|
||||
|
||||
// Extract the HTML content while preserving formatting
|
||||
let chapterContent = contentContainer.html();
|
||||
|
||||
// Filter out HTML comments
|
||||
if (chapterContent) {
|
||||
chapterContent = chapterContent.replace(/<!--[\s\S]*?-->/g, '');
|
||||
}
|
||||
|
||||
// Return properly formatted HTML string
|
||||
return chapterContent?.trim() || '';
|
||||
} catch (error) {
|
||||
console.error('Error parsing chapter:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KDTNovels();
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"filters": {
|
||||
"genre[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Genre",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Action",
|
||||
"value": "action"
|
||||
},
|
||||
{
|
||||
"label": "Adult",
|
||||
"value": "adult"
|
||||
},
|
||||
{
|
||||
"label": "Adventure",
|
||||
"value": "adventure"
|
||||
},
|
||||
{
|
||||
"label": "Comedy",
|
||||
"value": "comedy"
|
||||
},
|
||||
{
|
||||
"label": "Drama",
|
||||
"value": "drama"
|
||||
},
|
||||
{
|
||||
"label": "Ecchi",
|
||||
"value": "ecchi"
|
||||
},
|
||||
{
|
||||
"label": "Fantasy",
|
||||
"value": "fantasy"
|
||||
},
|
||||
{
|
||||
"label": "Gender Bender",
|
||||
"value": "gender-bender"
|
||||
},
|
||||
{
|
||||
"label": "Genderswap",
|
||||
"value": "genderswap"
|
||||
},
|
||||
{
|
||||
"label": "Harem",
|
||||
"value": "harem"
|
||||
},
|
||||
{
|
||||
"label": "Isekai",
|
||||
"value": "isekai"
|
||||
},
|
||||
{
|
||||
"label": "Martial Arts",
|
||||
"value": "martial-arts"
|
||||
},
|
||||
{
|
||||
"label": "Mature",
|
||||
"value": "mature"
|
||||
},
|
||||
{
|
||||
"label": "Monster Girls",
|
||||
"value": "monster-girls"
|
||||
},
|
||||
{
|
||||
"label": "Monsters",
|
||||
"value": "monsters"
|
||||
},
|
||||
{
|
||||
"label": "Reincarnation",
|
||||
"value": "reincarnation"
|
||||
},
|
||||
{
|
||||
"label": "Romance",
|
||||
"value": "romance"
|
||||
},
|
||||
{
|
||||
"label": "School Life",
|
||||
"value": "school-life"
|
||||
},
|
||||
{
|
||||
"label": "Seinen",
|
||||
"value": "seinen"
|
||||
},
|
||||
{
|
||||
"label": "Shounen",
|
||||
"value": "shounen"
|
||||
},
|
||||
{
|
||||
"label": "Slice of Life",
|
||||
"value": "slice-of-life"
|
||||
},
|
||||
{
|
||||
"label": "Survival",
|
||||
"value": "survival"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type[]": {
|
||||
"type": "Checkbox",
|
||||
"label": "Type",
|
||||
"value": [],
|
||||
"options": [
|
||||
{
|
||||
"label": "Light Novel (JP)",
|
||||
"value": "light-novel-jp"
|
||||
},
|
||||
{
|
||||
"label": "Web Novel",
|
||||
"value": "web-novel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "Picker",
|
||||
"label": "Status",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "All",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "Ongoing",
|
||||
"value": "ongoing"
|
||||
},
|
||||
{
|
||||
"label": "Hiatus",
|
||||
"value": "hiatus"
|
||||
},
|
||||
{
|
||||
"label": "Completed",
|
||||
"value": "completed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"type": "Picker",
|
||||
"label": "Order by",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Default",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "A-Z",
|
||||
"value": "title"
|
||||
},
|
||||
{
|
||||
"label": "Z-A",
|
||||
"value": "titlereverse"
|
||||
},
|
||||
{
|
||||
"label": "Latest Update",
|
||||
"value": "update"
|
||||
},
|
||||
{
|
||||
"label": "Latest Added",
|
||||
"value": "latest"
|
||||
},
|
||||
{
|
||||
"label": "Popular",
|
||||
"value": "popular"
|
||||
},
|
||||
{
|
||||
"label": "Rating",
|
||||
"value": "rating"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,5 +308,14 @@
|
||||
"lang": "English",
|
||||
"reverseChapters": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kdtnovels",
|
||||
"sourceSite": "https://kdtnovels.net/",
|
||||
"sourceName": "KDT Novels",
|
||||
"options": {
|
||||
"lang": "English",
|
||||
"reverseChapters": true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Reference in New Issue
Block a user