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();