Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -493,5 +494,6 @@ const PLUGINS: Plugin.PluginBase[] = [
p_243,
p_244,
p_245,
p_246,
];
export default PLUGINS;
309 changes: 309 additions & 0 deletions plugins/vietnamese/truyenss.ts
Original file line number Diff line number Diff line change
@@ -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<Element>) => {
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<string>();
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<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
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<Plugin.SourceNovel> {
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<string> {
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<Plugin.NovelItem[]> {
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();
Binary file added public/static/src/vi/truyenss/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading