From 76d60d0443e3501be4b37551020a5ba49c1375fe Mon Sep 17 00:00:00 2001
From: Binh Lam <161554543+ganzvu@users.noreply.github.com>
Date: Mon, 18 May 2026 14:20:59 -0500
Subject: [PATCH] feat: Add TruyenSS (#2192)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add TruyenSS Vietnamese source for truyenss.com
Implements novel pages, hash-style chapter list, POST chapter loader
(/layout/xem-chuong.php), genre filters for popular listings, and
multi-pattern search. Includes saved HTML fixtures used for selectors.
* fix(truyenss): fetch chapter HTML via GET like the site
TruyenSS loads chapters with jQuery $.ajax, which defaults to GET.
POST to xem-chuong.php returns an empty body, causing empty chapter
errors in the app while WebView (GET + hash) still worked.
Bump plugin version to 1.0.1.
* fix(truyenss): address PR review — version 1.0.0, defaultCover, cleanup
Remove README note and truyenss HTML fixtures. Drop unused resolveUrl and
full-URL handling in parseNovel. Use defaultCover for listing items and when
novel page has no cover image.
* fix(truyenss): browse list covers — scrape thumbs, stable placeholder URL
LNReader applies plugin Referer headers to browse tiles; external blob
placeholders can fail. List layout omits defaultCover when cover is unset.
Prefer real listing images; use raw GitHub placeholder URL aligned with the app.
* fix(truyenss): resolve listing covers (.card sibling img, ../ URLs)
Genre/search cards put the thumbnail in col-2 beside the stretched link,
not inside the anchor. Image src uses ../user-upload/... which must be
resolved against the list page URL. Use the site no_avatar.jpg placeholder
so browse tiles load with the plugin Referer.
* fix(playground): readable novel summary + TruyenSS paragraph breaks
- Parse playground: normalize CR/LF and stray spaces; use whitespace-pre-line,
max-w-prose, leading-7, break-words, explicit font-sans for robust wrapping
when webfonts misbehave.
- TruyenSS: preserve
/
structure like novelfire (join with newlines)
instead of collapsing the whole intro to one line.
* fix(proxy): avoid static zstd import so Vite starts on older Node
zstdDecompressSync is not exported from zlib on Node versions before it was
added. Importing it at load time broke vite.config (and the dev server)
with SyntaxError. Only call zstd at runtime when content-encoding is zstd
and throw a clear error if the runtime lacks support.
* fix(proxy): correct decompress error message for zstd
* Revert "fix(proxy): correct decompress error message for zstd"
This reverts commit ab5c4f0f6b0059589956693da534294a49500be0.
* Revert "fix(proxy): avoid static zstd import so Vite starts on older Node"
This reverts commit 58ddf6c6003fa3828a5a6627f77b4ea3aa3cfca4.
* fix(dev): proxy URL for https targets + disable auto-open by default
- Reconstruct target URL when the dev server path is /https://... (from
window.fetch prefixing localhost); the old + path form produced
an invalid URL and broke all proxied plugin requests.
- Only open a browser when VITE_OPEN=true so missing xdg-open does not error.
* Revert "fix(dev): proxy URL for https targets + disable auto-open by default"
This reverts commit b0de564fca75e756ef11899e77038fc594f18f97.
* revert: drop out-of-scope parse-novel playground summary changes
Restore src/components/parse-novel.tsx to pre-093668b state; TruyenSS
summary parsing in the plugin is unchanged.
---------
Co-authored-by: Cursor Agent
---
plugins/index.ts | 2 +
plugins/vietnamese/truyenss.ts | 309 +++++++++++++++++++++++++
public/static/src/vi/truyenss/icon.png | Bin 0 -> 624 bytes
3 files changed, 311 insertions(+)
create mode 100644 plugins/vietnamese/truyenss.ts
create mode 100644 public/static/src/vi/truyenss/icon.png
diff --git a/plugins/index.ts b/plugins/index.ts
index c152366..1384ad9 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 0000000..e9415ff
--- /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 0000000000000000000000000000000000000000..db5d0538f92a6c1e01b4fff648676566da1035af
GIT binary patch
literal 624
zcmV-$0+0QPP)F1DMG=cx|NnanjnhahnM`|m=nVHU5;v+xLkV>p#W_SZ~uPSzy?w7>E$Sc0a^6dk%tC2`CND~pq6uYQo#m>oEIlGO5ooO
zhI1LwfNn*S2N$ph-Si0_4p8Ex>IA(-x|PX@ZN~0_|Nd3Qw>emb!vGZVld3lWlsx