diff --git a/plugins/index.ts b/plugins/index.ts index c152366d6..1384ad90c 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -245,6 +245,7 @@ import p_242 from '@plugins/ukrainian/smakolykytl'; import p_243 from '@plugins/vietnamese/LNHako'; import p_244 from '@plugins/vietnamese/lightnovelvn'; import p_245 from '@plugins/vietnamese/nettruyen'; +import p_246 from '@plugins/vietnamese/truyenss'; const PLUGINS: Plugin.PluginBase[] = [ p_0, @@ -493,5 +494,6 @@ const PLUGINS: Plugin.PluginBase[] = [ p_243, p_244, p_245, + p_246, ]; export default PLUGINS; diff --git a/plugins/vietnamese/truyenss.ts b/plugins/vietnamese/truyenss.ts new file mode 100644 index 000000000..e9415ff75 --- /dev/null +++ b/plugins/vietnamese/truyenss.ts @@ -0,0 +1,309 @@ +import { CheerioAPI, load as parseHTML, type Cheerio } from 'cheerio'; +import type { Element } from 'domhandler'; +import { fetchApi } from '@libs/fetch'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { NovelStatus } from '@libs/novelStatus'; +import { Plugin } from '@/types/plugin'; + +const CHAPTER_PATH = /^\/truyen\/([^/]+)\/chuong-(\d+)$/; + +class TruyenSS implements Plugin.PluginBase { + id = 'truyenss.com'; + name = 'TruyenSS'; + icon = 'src/vi/truyenss/icon.png'; + site = 'https://truyenss.com'; + version = '1.0.0'; + + imageRequestInit: Plugin.ImageRequestInit = { + headers: { Referer: this.site + '/' }, + }; + + filters = { + genre: { + type: FilterTypes.Picker, + label: 'Thể loại', + value: 'tien-hiep', + options: [ + { label: 'Tiên Hiệp', value: 'tien-hiep' }, + { label: 'Nữ Cường', value: 'nu-cuong' }, + { label: 'Xuyên Không', value: 'xuyen-khong' }, + { label: 'Điền Văn', value: 'dien-van' }, + { label: 'Thám Hiểm', value: 'tham-hiem' }, + { label: 'Linh Dị', value: 'linh-di' }, + { label: 'Truyện Ngược', value: 'truyen-nguoc' }, + { label: 'Truyện Sủng', value: 'truyen-sung' }, + { label: 'Đông Phương', value: 'dong-phuong' }, + { label: 'Hài Hước', value: 'hai-huoc' }, + { label: 'Hiện Đại', value: 'hien-dai' }, + { label: 'Quân Sự', value: 'quan-su' }, + { label: 'Mạt Thế', value: 'mat-the' }, + { label: 'Trọng Sinh', value: 'trong-sinh' }, + { label: 'Đồng Nhân', value: 'dong-nhan' }, + { label: 'Quan Trường', value: 'quan-truong' }, + { label: 'Cổ Đại', value: 'co-dai' }, + { label: 'Hệ Thống', value: 'he-thong' }, + { label: 'Phương Tây', value: 'phuong-tay' }, + { label: 'Lịch Sử', value: 'lich-su' }, + { label: 'Ngôn Tình', value: 'ngon-tinh' }, + { label: 'Huyền Huyễn', value: 'huyen-huyen' }, + { label: 'Kiếm Hiệp', value: 'kiem-hiep' }, + { label: 'Võng Du', value: 'vong-du' }, + { label: 'Trinh Thám', value: 'trinh-tham' }, + { label: 'Khoa Huyễn', value: 'khoa-huyen' }, + { label: 'Dị Năng', value: 'di-nang' }, + { label: 'Gia Đấu Cung Đấu', value: 'gia-dau-cung-dau' }, + { label: 'Góc Nhìn Nữ', value: 'goc-nhin-nu' }, + { label: 'Góc Nhìn Nam', value: 'goc-nhin-nam' }, + ], + }, + } satisfies Filters; + + /** Host-local placeholder from the site (og:image); works with plugin Referer headers. */ + private get sitePlaceholderCover(): string { + return `${this.site}/images/no_avatar.jpg`; + } + + private resolveCoverUrl( + raw: string | undefined, + pageUrl: string, + ): string | undefined { + if (!raw) return undefined; + const u = raw.trim(); + if (!u || u.startsWith('data:')) return undefined; + try { + if (u.startsWith('//')) return 'https:' + u; + if (u.startsWith('http')) return u; + return new URL(u, pageUrl).href; + } catch { + return undefined; + } + } + + private coverFromTruyenAnchor( + loadedCheerio: CheerioAPI, + el: Element, + pageUrl: string, + ): string { + const $a = loadedCheerio(el); + const fromImg = (img: Cheerio) => { + const src = + img.attr('data-src') || + img.attr('data-lazy-src') || + img.attr('data-original') || + img.attr('src'); + return this.resolveCoverUrl(src, pageUrl); + }; + + const inner = fromImg($a.find('img').first()); + if (inner) return inner; + + const cardImg = $a.closest('.card').find('img').first(); + const fromCard = fromImg(cardImg); + if (fromCard) return fromCard; + + const rowImg = $a.closest('.row').find('img').first(); + const fromRow = fromImg(rowImg); + if (fromRow) return fromRow; + + return this.sitePlaceholderCover; + } + + private collectTruyenLinks( + loadedCheerio: CheerioAPI, + pageUrl: string, + ): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + loadedCheerio('a[href^="/truyen/"]').each((_, el) => { + const href = el.attribs['href']; + if (!href || href.split('/').length !== 3) return; + const path = href.split('?')[0]!; + if (seen.has(path)) return; + seen.add(path); + const name = loadedCheerio(el).text().replace(/\s+/g, ' ').trim(); + if (!name) return; + const cover = this.coverFromTruyenAnchor(loadedCheerio, el, pageUrl); + novels.push({ path, name, cover }); + }); + return novels; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + if (showLatestNovels) { + if (pageNo > 1) return []; + const body = await fetchApi(this.site + '/').then(r => r.text()); + return this.collectTruyenLinks(parseHTML(body), `${this.site}/`); + } + const genre = filters?.genre.value ?? 'tien-hiep'; + const url = + pageNo <= 1 + ? `${this.site}/${genre}` + : `${this.site}/${genre}?page=${pageNo}`; + const body = await fetchApi(url).then(r => r.text()); + return this.collectTruyenLinks(parseHTML(body), url); + } + + private parseStatusLine(raw: string): string { + const t = raw.toLowerCase(); + if (t.includes('hoàn') || t.includes('full')) return NovelStatus.Completed; + if (t.includes('đang') || t.includes('ra chương')) + return NovelStatus.Ongoing; + return NovelStatus.Unknown; + } + + private parseChapters( + loadedCheerio: CheerioAPI, + novelPath: string, + ): Plugin.ChapterItem[] { + const chapters: Plugin.ChapterItem[] = []; + const h2 = loadedCheerio('h2') + .filter((_, el) => loadedCheerio(el).text().includes('Danh Sách Chương')) + .first(); + const container = h2.next('div.position-relative'); + const anchors = container.length + ? container.find('a[href^="#"]') + : loadedCheerio('#inner-page a[href^="#"]'); + + anchors.each((_, el) => { + const href = el.attribs['href']; + if (!href?.startsWith('#')) return; + const num = Number(href.slice(1)); + if (!Number.isFinite(num) || num <= 0) return; + const name = loadedCheerio(el).text().replace(/\s+/g, ' ').trim(); + chapters.push({ + name: name || `Chương ${num}`, + path: `${novelPath}/chuong-${num}`, + chapterNumber: num, + }); + }); + chapters.sort((a, b) => (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0)); + return chapters; + } + + async parseNovel(novelPath: string): Promise { + const path = novelPath; + const url = this.site + path; + const body = await fetchApi(url).then(r => r.text()); + const loadedCheerio = parseHTML(body); + + const novel: Plugin.SourceNovel = { + path, + name: + loadedCheerio('#inner-page > h1').first().text().trim() || + loadedCheerio('main#main h1').first().text().trim() || + 'Không có tiêu đề', + chapters: [], + }; + + const coverSrc = loadedCheerio('.info_truyen img.avatar').attr('src'); + novel.cover = + this.resolveCoverUrl(coverSrc, url) ?? this.sitePlaceholderCover; + + const infoBlock = loadedCheerio('.info_truyen').first(); + const infoText = infoBlock.text(); + const authorMatch = infoText.match(/Tác\s*Giả:\s*([^\n\r]+)/i); + if (authorMatch) novel.author = authorMatch[1]!.trim(); + + const statusMatch = infoText.match(/Tình\s*Trạng:\s*([^\n\r]+)/i); + if (statusMatch) novel.status = this.parseStatusLine(statusMatch[1]!); + + novel.genres = loadedCheerio('p.tags a.badge') + .toArray() + .map(a => loadedCheerio(a).text().trim()) + .filter(Boolean) + .join(', '); + + const intro = loadedCheerio( + '#inner-page .position-relative.mt-4 .line-height-3', + ).first(); + if (intro.length) { + const block = intro.clone(); + block.find('script, style').remove(); + block.find('br').replaceWith('\n'); + block.find('p').before('\n').after('\n\n'); + novel.summary = block + .text() + .split('\n') + .map(line => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + } + + novel.chapters = this.parseChapters(loadedCheerio, path); + return novel; + } + + private extractChapterBody($: CheerioAPI): string { + $('script, style').remove(); + let best = ''; + let bestP = 0; + $('div').each((_, el) => { + const div = $(el); + const pCount = div.find('p').length; + if (pCount > bestP) { + bestP = pCount; + best = div.html() ?? ''; + } + }); + if (bestP >= 2) return best; + const fallback = $('body').html() ?? $.root().html() ?? ''; + return fallback; + } + + async parseChapter(chapterPath: string): Promise { + let rel = chapterPath; + if (rel.startsWith(this.site)) { + rel = rel.slice(this.site.length); + } + const m = rel.match(CHAPTER_PATH); + if (!m) throw new Error(`TruyenSS: invalid chapter path: ${rel}`); + const folder = m[1]!; + const chuong = m[2]!; + const referer = `${this.site}/truyen/${folder}`; + + const qs = new URLSearchParams({ folder, chuong }).toString(); + const body = await fetchApi(`${this.site}/layout/xem-chuong.php?${qs}`, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + Referer: referer, + }, + }).then(r => r.text()); + + if (!body.trim()) { + throw new Error('TruyenSS: empty chapter response'); + } + + return this.extractChapterBody(parseHTML(body)); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const q = encodeURIComponent(searchTerm.trim()); + if (!q) return []; + + const tryUrls = [ + `${this.site}/tim-kiem?q=${q}&page=${pageNo}`, + `${this.site}/tim-kiem/${q}?page=${pageNo}`, + `${this.site}/tim-truyen?tu-khoa=${q}&page=${pageNo}`, + ]; + + for (const tryUrl of tryUrls) { + const body = await fetchApi(tryUrl).then(r => r.text()); + const novels = this.collectTruyenLinks(parseHTML(body), tryUrl); + if (novels.length) return novels; + } + return []; + } +} + +export default new TruyenSS(); diff --git a/public/static/src/vi/truyenss/icon.png b/public/static/src/vi/truyenss/icon.png new file mode 100644 index 000000000..db5d0538f Binary files /dev/null and b/public/static/src/vi/truyenss/icon.png differ