From 57871aa1e2507e81ad74c39f466b7f4160f81a3f Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 22:33:07 +0300 Subject: [PATCH 1/8] feat(english): add We Tried TLS source plugin --- plugins/english/wetriedtls.ts | 502 ++++++++++++++++++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 plugins/english/wetriedtls.ts diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts new file mode 100644 index 000000000..06effbf79 --- /dev/null +++ b/plugins/english/wetriedtls.ts @@ -0,0 +1,502 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; + +const API = 'https://api.wetriedtls.com'; +const SITE = 'https://wetriedtls.com'; + +type NovelCard = { + slug: string; + title: string; + cover: string; +}; + +type NovelDetails = { + id: number; + name: string; + author: string; + genres: string[]; + status: string; + cover: string; + summary: string; +}; + +type ChapterInfo = { + slug: string; + name: string; + number: number; + publishedAt: string; + /** True for chapters behind the site's paywall (from the /paid endpoint). */ + locked: boolean; +}; + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** Decode a handful of HTML entities; the site uses a small set. */ +function decodeEntities(s: string): string { + return s + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/’|‘/g, "'") + .replace(/”|“/g, '"') + .replace(/—/g, '—') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10))); +} + +/** Strip tags, collapse whitespace. */ +function stripHtml(html: string): string { + return decodeEntities(html.replace(/<[^>]+>/g, ' ')) + .replace(/[ \t\xa0]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Next.js app-router pages embed their data in + * self.__next_f.push([1,""]) + * scripts. Decode every payload into one searchable text blob. + * The closing tag match tolerates an optional semicolon / whitespace + * (some Next.js versions emit `);`), so chunks are never + * silently skipped due to formatting. + */ +function extractFlightText(html: string): string { + const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)\s*;?\s*<\/script>/g; + let out = ''; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null) { + try { + out += JSON.parse('"' + m[1] + '"'); + } catch { + /* skip malformed chunk */ + } + } + return out; +} + +/** + * Slice a JS string by UTF-8 byte offsets (the flight protocol's `T` + * length prefix counts UTF-8 bytes of the row payload, not UTF-16 chars). + */ +function sliceUtf8Bytes(s: string, start: number, byteLen: number): string { + let bytes = 0; + let i = start; + while (i < s.length && bytes < byteLen) { + const code = s.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { + const next = s.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + i += 2; + bytes += 4; + continue; + } + } + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3; + i++; + } + return s.slice(start, i); +} + +/** Inner text of one

...

block, tags stripped. */ +function paragraphText(p: string): string { + return decodeEntities(p.replace(/<[^>]+>/g, '')).trim(); +} + +function isTitleRepeat(p: string): boolean { + // A paragraph that is nothing but bold text, e.g. the repeated + // series / chapter title the site prepends to every chapter body. + const inner = p + .replace(/^]*>/i, '') + .replace(/<\/p>$/i, '') + .trim(); + return /^[\s\S]*<\/strong>$/.test(inner); +} + +function isPromoParagraph(p: string): boolean { + // A block that carries an illustration is content, never promo — even + // when it has no text of its own (e.g.

). + if (/]/i.test(p)) return false; + const t = paragraphText(p).toLowerCase(); + if (!t || t === '= = =') return true; + if (t.indexOf('we tried translations') !== -1) return true; + if (t.indexOf('dsc.gg') !== -1 || t.indexOf('join our discord') !== -1) + return true; + return false; +} + +/** Parse the /query API response (catalog + search share the shape). */ +function parseQueryResults(jsonText: string): { + items: NovelCard[]; + lastPage: number; +} { + const root = safeJson(jsonText); + const items: NovelCard[] = []; + let lastPage = 1; + if (isRecord(root)) { + const meta = root.meta; + if (isRecord(meta) && typeof meta.last_page === 'number') + lastPage = meta.last_page; + const data = Array.isArray(root.data) ? root.data : []; + for (const it of data) { + if (!isRecord(it)) continue; + if (it.series_type && it.series_type !== 'Novel') continue; + const slug = str(it.series_slug).trim(); + const title = str(it.title).trim(); + if (!slug || !title) continue; + items.push({ + slug, + title: decodeEntities(title), + cover: str(it.thumbnail), + }); + } + } + return { items, lastPage }; +} + +/** Parse the /series/{slug} API response. */ +function parseSeriesDetail(jsonText: string): NovelDetails | null { + const s = safeJson(jsonText); + if (!isRecord(s) || typeof s.id !== 'number') return null; + const tags = Array.isArray(s.tags) ? s.tags : []; + return { + id: s.id, + name: decodeEntities(str(s.title)), + author: decodeEntities(str(s.author)), + genres: tags + .map(t => (isRecord(t) ? decodeEntities(str(t.name)) : '')) + .filter(g => g.length > 0), + status: str(s.status), + cover: str(s.thumbnail), + summary: stripHtml(str(s.description)), + }; +} + +/** + * Parse one page of the /chapters/{seriesId} API response. + * Pass locked=true for pages from the /paid endpoint. + */ +function parseChapterList( + jsonText: string, + locked = false, +): { items: ChapterInfo[]; lastPage: number } { + const root = safeJson(jsonText); + const items: ChapterInfo[] = []; + let lastPage = 1; + if (isRecord(root)) { + const meta = root.meta; + if (isRecord(meta) && typeof meta.last_page === 'number') + lastPage = meta.last_page; + const data = Array.isArray(root.data) ? root.data : []; + for (const c of data) { + if (!isRecord(c)) continue; + const slug = str(c.chapter_slug).trim(); + const name = str(c.chapter_name).trim(); + if (!slug || !name) continue; + const title = str(c.chapter_title).trim(); + const idx = parseFloat(str(c.index)); + items.push({ + slug, + name: title ? name + ': ' + decodeEntities(title) : name, + number: isNaN(idx) ? 0 : idx, + publishedAt: str(c.created_at), + locked, + }); + } + } + return { items, lastPage }; +} + +/** + * The display name for a chapter in the app. Locked (paywalled) chapters + * get a lock prefix so readers can see which ones are premium before + * tapping them. LNReader sorts by chapterNumber, so the prefix never + * affects chapter order. + */ +function chapterDisplayName(c: ChapterInfo): string { + return c.locked ? '🔒 ' + c.name : c.name; +} + +/** + * Route a cover through a fast image proxy at a list-friendly size. + * The site's own covers are up to ~1 MB (they load painfully slowly in + * the app's novel list) and the CDN offers no smaller variant, so we + * request a 400px-wide webp instead. Non-URL values pass through. + */ +function coverUrl(thumbnail: string): string { + const t = (thumbnail || '').trim(); + if (!/^https?:\/\//i.test(t)) return t; + const bare = t.replace(/^https?:\/\//i, ''); + return ( + 'https://images.weserv.nl/?url=' + + encodeURIComponent(bare) + + '&w=400&q=80&output=webp' + ); +} + +type ChapterContentResult = + | { status: 'ok'; html: string } + | { status: 'premium' | 'notfound' | 'empty' }; + +/** + * Extract the chapter body from a chapter page's HTML. + * + * The page is a Next.js app-router page: the chapter record carries + * `"chapter_content":"$"` and the body HTML lives in the flight + * row `:T,` that follows, where is the exact UTF-8 byte + * length of the payload. Slicing by that byte count is required — a + * "next row" lookahead overshoots into flight metadata and corrupts the + * HTML. Returns the cleaned HTML, or a non-ok status for locked / + * missing / unparseable chapters. + */ +function parseChapterContent(html: string): ChapterContentResult { + if (/this chapter is premium!/i.test(html)) return { status: 'premium' }; + // Only the real element counts for the 404 check: Next.js flight + // data always embeds a notFound template containing similar wording. + const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html); + if (title && /^\s*404/i.test(title[1])) return { status: 'notfound' }; + + const flight = extractFlightText(html); + // chapter_content is either a flight-row reference ("$<rowId>") or the + // chapter HTML inline (gallery/illustration chapters). The value is a + // JSON string, so internal quotes arrive escaped. + const contentM = /"chapter_content":"((?:[^"\\]|\\.)*)"/.exec(flight); + if (!contentM) return { status: 'empty' }; + let raw: string; + try { + raw = JSON.parse('"' + contentM[1] + '"'); + } catch { + return { status: 'empty' }; + } + + let payload: string; + const rowRef = /^\$([0-9a-z]{1,4})$/.exec(raw); + if (rowRef) { + // The row looks like `<rowId>:T<hex>,<payload>` where <hex> is the exact + // UTF-8 byte length of the payload. Respecting it is the only reliable + // end boundary: flight metadata follows the payload on the same line, + // so a "next row" lookahead overshoots. + const rowRe = new RegExp( + '\\n' + + rowRef[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + ':T([0-9a-f]+),', + ); + const row = rowRe.exec(flight); + if (!row) return { status: 'empty' }; + const byteLen = parseInt(row[1], 16); + if (!(byteLen > 0)) return { status: 'empty' }; + const payloadStart = row.index + row[0].length; + payload = sliceUtf8Bytes(flight, payloadStart, byteLen); + } else if (/^\s*</.test(raw)) { + payload = raw; + } else { + return { status: 'empty' }; + } + if (!payload) return { status: 'empty' }; + + // Paragraph breaks inside the payload are literal \r\n / \n sequences. + const body = payload + .replace(/\\r\\n/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\n') + .trim(); + if (!body) return { status: 'empty' }; + + // Split into top-level blocks — paragraphs, headings, figures and + // standalone images, in document order — and trim the site's promo + // header / footer (banner, series/chapter title repeats, discord plug). + const blocks = body.match( + /<p[\s\S]*?<\/p>|<h[1-6][\s\S]*?<\/h[1-6]>|<figure[\s\S]*?<\/figure>|<img[^>]*>/gi, + ) || [body]; + let start = 0; + let end = blocks.length; + const isEdgeJunk = (p: string) => isPromoParagraph(p) || isTitleRepeat(p); + while (start < end && isEdgeJunk(blocks[start])) start++; + while (end > start && isEdgeJunk(blocks[end - 1])) end--; + const cleaned = blocks.slice(start, end).join('\n'); + if (!paragraphText(cleaned)) return { status: 'empty' }; + return { status: 'ok', html: cleaned }; +} + +function mapStatus(s: string): string { + if (s === 'Ongoing') return NovelStatus.Ongoing; + if (s === 'Completed') return NovelStatus.Completed; + if (s === 'Hiatus' || s === 'On Hiatus') return NovelStatus.OnHiatus; + if (s === 'Cancelled' || s === 'Dropped') return NovelStatus.Cancelled; + return NovelStatus.Unknown; +} + +class WeTriedTLS implements Plugin.PluginBase { + id = 'wetriedtls'; + name = 'We Tried TLS'; + icon = 'src/en/wetriedtls/icon.png'; + site = SITE; + version = '1.0.1'; + + async popularNovels( + pageNo: number, + _options: Plugin.PopularNovelsOptions, + ): Promise<Plugin.NovelItem[]> { + const page = parseQueryResults( + await fetchText(API + '/query?adult=true&query_string=&page=' + pageNo), + ); + if (pageNo > page.lastPage) return []; + return page.items.map(n => ({ + name: n.title, + path: n.slug, + cover: coverUrl(n.cover), + })); + } + + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { + const slug = novelPath.split('/').filter(Boolean).pop() || ''; + const detail = parseSeriesDetail(await fetchText(API + '/series/' + slug)); + if (!detail) throw new Error('Could not load novel details'); + + // The chapter list is paginated (500 per page keeps it to ~2 + // requests even for the longest series). Free chapters come from + // /chapters/{id} and paywalled chapters from /chapters/{id}/paid; + // the two are merged so locked chapters show up with a lock prefix. + // Opening a locked chapter shows a notice: it needs a paid + // subscription on the website and cannot be read here. + const all: ChapterInfo[] = []; + let pageNo = 1; + let lastPage = 1; + do { + const page = parseChapterList( + await fetchText( + API + + '/chapters/' + + detail.id + + '?page=' + + pageNo + + '&perPage=500&order=asc', + ), + ); + lastPage = page.lastPage; + for (const c of page.items) all.push(c); + pageNo++; + } while (pageNo <= lastPage); + + // Paid chapters are a bonus, not a requirement: if this endpoint + // ever fails, the novel still loads with its free chapters. + try { + let paidPageNo = 1; + let paidLastPage = 1; + do { + const page = parseChapterList( + await fetchText( + API + + '/chapters/' + + detail.id + + '/paid?query=&page=' + + paidPageNo + + '&perPage=1000&order=asc', + ), + true, + ); + paidLastPage = page.lastPage; + for (const c of page.items) all.push(c); + paidPageNo++; + } while (paidPageNo <= paidLastPage); + } catch { + // ignore: free chapters are already collected above + } + + const seen: Record<string, boolean> = {}; + const chapters: Plugin.ChapterItem[] = []; + all + .filter(c => { + if (!c.slug || seen[c.slug]) return false; + seen[c.slug] = true; + return true; + }) + .sort((a, b) => a.number - b.number) + .forEach(c => { + chapters.push({ + name: chapterDisplayName(c), + path: slug + '/' + c.slug, + releaseTime: c.publishedAt, + chapterNumber: c.number, + }); + }); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: detail.name, + status: mapStatus(detail.status), + }; + if (detail.cover) novel.cover = coverUrl(detail.cover); + if (detail.author) novel.author = detail.author; + if (detail.genres.length) novel.genres = detail.genres.join(', '); + if (detail.summary) novel.summary = detail.summary; + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise<string> { + const result = parseChapterContent( + await fetchText(SITE + '/series/' + chapterPath), + ); + if (result.status === 'ok') return result.html; + if (result.status === 'premium') { + return ( + '<p><strong>This chapter is premium on We Tried TLS.</strong></p>' + + '<p>It requires a paid subscription on the website and cannot be read here. ' + + 'Free chapters of this novel still work.</p>' + ); + } + if (result.status === 'notfound') { + return ( + '<p><strong>This chapter is no longer available on We Tried TLS.</strong></p>' + + '<p>It may have been removed or moved. Refresh the novel to update the chapter list.</p>' + ); + } + return ( + '<p><strong>Could not load this chapter.</strong></p>' + + '<p>It may be temporarily unavailable on We Tried TLS.</p>' + ); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise<Plugin.NovelItem[]> { + const page = parseQueryResults( + await fetchText( + API + + '/query?adult=true&query_string=' + + encodeURIComponent(searchTerm) + + '&page=' + + pageNo, + ), + ); + if (pageNo > page.lastPage) return []; + return page.items.map(n => ({ + name: n.title, + path: n.slug, + cover: coverUrl(n.cover), + })); + } + + resolveUrl = (path: string, _isNovel?: boolean): string => + SITE + '/series/' + path; +} + +export default new WeTriedTLS(); From 48dbe998fe4f77129009f59624945ab5e3f50de1 Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Sat, 26 Sep 2026 22:33:30 +0300 Subject: [PATCH 2/8] feat(english): add We Tried TLS icon --- public/static/src/en/wetriedtls/icon.png | Bin 0 -> 10675 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/static/src/en/wetriedtls/icon.png diff --git a/public/static/src/en/wetriedtls/icon.png b/public/static/src/en/wetriedtls/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a2e303eac40a69572316afd6d73cc115a33d764b GIT binary patch literal 10675 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 Z{{KNMr(hS{NgDtF002ovPDHLkV1nkg5n=!U literal 0 HcmV?d00001 From 7a35240bbe22913596848fd7854fd71f88a3ac21 Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Sat, 26 Sep 2026 22:41:25 +0300 Subject: [PATCH 3/8] Shrink chapter illustrations via image proxy --- plugins/english/wetriedtls.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts index 06effbf79..e4682798b 100644 --- a/plugins/english/wetriedtls.ts +++ b/plugins/english/wetriedtls.ts @@ -233,6 +233,25 @@ function chapterDisplayName(c: ChapterInfo): string { return c.locked ? '🔒 ' + c.name : c.name; } +function proxiedImageUrl(url: string, width: number): string { + const bare = url.replace(/^https?:\/\//i, ''); + return ( + 'https://images.weserv.nl/?url=' + + encodeURIComponent(bare) + + '&w=' + + width + + '&q=80&output=webp' + ); +} + +function shrinkIllustrations(html: string): string { + return html.replace( + /<img\b([^>]*?)\bsrc="(https?:\/\/media\.reaperscans\.net\/[^"]+)"([^>]*?)>/gi, + (_m, pre, src, post) => + '<img' + pre + ' src="' + proxiedImageUrl(src, 800) + '"' + post + '>', + ); +} + /** * Route a cover through a fast image proxy at a list-friendly size. * The site's own covers are up to ~1 MB (they load painfully slowly in @@ -242,12 +261,7 @@ function chapterDisplayName(c: ChapterInfo): string { function coverUrl(thumbnail: string): string { const t = (thumbnail || '').trim(); if (!/^https?:\/\//i.test(t)) return t; - const bare = t.replace(/^https?:\/\//i, ''); - return ( - 'https://images.weserv.nl/?url=' + - encodeURIComponent(bare) + - '&w=400&q=80&output=webp' - ); + return proxiedImageUrl(t, 400); } type ChapterContentResult = @@ -331,7 +345,7 @@ function parseChapterContent(html: string): ChapterContentResult { while (end > start && isEdgeJunk(blocks[end - 1])) end--; const cleaned = blocks.slice(start, end).join('\n'); if (!paragraphText(cleaned)) return { status: 'empty' }; - return { status: 'ok', html: cleaned }; + return { status: 'ok', html: shrinkIllustrations(cleaned) }; } function mapStatus(s: string): string { @@ -347,7 +361,7 @@ class WeTriedTLS implements Plugin.PluginBase { name = 'We Tried TLS'; icon = 'src/en/wetriedtls/icon.png'; site = SITE; - version = '1.0.1'; + version = '1.0.3'; async popularNovels( pageNo: number, From 0727b6817bcec792dfc00a83cf3f6a8b62d143ea Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Sat, 26 Sep 2026 22:50:36 +0300 Subject: [PATCH 4/8] Add status filter to catalog --- plugins/english/wetriedtls.ts | 56 +++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts index e4682798b..b00eb3c6e 100644 --- a/plugins/english/wetriedtls.ts +++ b/plugins/english/wetriedtls.ts @@ -1,8 +1,22 @@ import { fetchText } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; const API = 'https://api.wetriedtls.com'; + +/** + * Build the catalog browse URL for a page. The API honors the `status` + * query param (Ongoing / Completed / Dropped / Canceled) but ignores + * `tags` and `sort` params, so status is the only exposed filter. + * 'all' (or empty) means no status filtering. + */ +function catalogUrl(pageNo: number, status?: string): string { + let url = API + '/query?adult=true&query_string=&page=' + pageNo; + const s = (status || '').trim(); + if (s && s !== 'all') url += '&status=' + encodeURIComponent(s); + return url; +} const SITE = 'https://wetriedtls.com'; type NovelCard = { @@ -356,20 +370,52 @@ function mapStatus(s: string): string { return NovelStatus.Unknown; } +// --- Catalog filters ---------------------------------------------------- +// The API honors `status` but ignores `tags` and `sort`, so the filter +// menu offers status only. +const STATUS_FILTER_OPTIONS = [ + { label: 'All', value: 'all' }, + { label: 'Ongoing', value: 'Ongoing' }, + { label: 'Completed', value: 'Completed' }, + { label: 'Dropped', value: 'Dropped' }, + { label: 'Canceled', value: 'Canceled' }, +] as const; + +/** + * Pull a plain string value out of the app's filter payload, which may be + * the raw string or a { type, value } wrapper object. + */ +function extractFilterValue(filters: unknown, key: string): string { + if (!filters || typeof filters !== 'object') return ''; + const f = (filters as Record<string, unknown>)[key]; + if (f === null || f === undefined) return ''; + const v = + typeof f === 'object' && 'value' in f ? (f as { value: unknown }).value : f; + return typeof v === 'string' ? v : ''; +} + class WeTriedTLS implements Plugin.PluginBase { id = 'wetriedtls'; name = 'We Tried TLS'; icon = 'src/en/wetriedtls/icon.png'; site = SITE; - version = '1.0.3'; + version = '1.0.4'; + + filters = { + status: { + type: FilterTypes.Picker, + label: 'Status', + value: 'all', + options: STATUS_FILTER_OPTIONS, + }, + } satisfies Filters; async popularNovels( pageNo: number, - _options: Plugin.PopularNovelsOptions, + { filters }: Plugin.PopularNovelsOptions<typeof this.filters>, ): Promise<Plugin.NovelItem[]> { - const page = parseQueryResults( - await fetchText(API + '/query?adult=true&query_string=&page=' + pageNo), - ); + const status = extractFilterValue(filters, 'status'); + const page = parseQueryResults(await fetchText(catalogUrl(pageNo, status))); if (pageNo > page.lastPage) return []; return page.items.map(n => ({ name: n.title, From a7d7dc5a749410d1ad07dc319795e91556f3fe8d Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Mon, 28 Sep 2026 12:16:49 +0300 Subject: [PATCH 5/8] Remove wetriedtls.ts (replaced in next commit) --- plugins/english/wetriedtls.ts | 562 ---------------------------------- 1 file changed, 562 deletions(-) delete mode 100644 plugins/english/wetriedtls.ts diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts deleted file mode 100644 index b00eb3c6e..000000000 --- a/plugins/english/wetriedtls.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { fetchText } from '@libs/fetch'; -import { Plugin } from '@/types/plugin'; -import { NovelStatus } from '@libs/novelStatus'; -import { Filters, FilterTypes } from '@libs/filterInputs'; - -const API = 'https://api.wetriedtls.com'; - -/** - * Build the catalog browse URL for a page. The API honors the `status` - * query param (Ongoing / Completed / Dropped / Canceled) but ignores - * `tags` and `sort` params, so status is the only exposed filter. - * 'all' (or empty) means no status filtering. - */ -function catalogUrl(pageNo: number, status?: string): string { - let url = API + '/query?adult=true&query_string=&page=' + pageNo; - const s = (status || '').trim(); - if (s && s !== 'all') url += '&status=' + encodeURIComponent(s); - return url; -} -const SITE = 'https://wetriedtls.com'; - -type NovelCard = { - slug: string; - title: string; - cover: string; -}; - -type NovelDetails = { - id: number; - name: string; - author: string; - genres: string[]; - status: string; - cover: string; - summary: string; -}; - -type ChapterInfo = { - slug: string; - name: string; - number: number; - publishedAt: string; - /** True for chapters behind the site's paywall (from the /paid endpoint). */ - locked: boolean; -}; - -function safeJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return undefined; - } -} - -function isRecord(v: unknown): v is Record<string, unknown> { - return typeof v === 'object' && v !== null; -} - -function str(v: unknown): string { - return typeof v === 'string' ? v : ''; -} - -/** Decode a handful of HTML entities; the site uses a small set. */ -function decodeEntities(s: string): string { - return s - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/�?39;/g, "'") - .replace(/’|‘/g, "'") - .replace(/”|“/g, '"') - .replace(/—/g, '—') - .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10))); -} - -/** Strip tags, collapse whitespace. */ -function stripHtml(html: string): string { - return decodeEntities(html.replace(/<[^>]+>/g, ' ')) - .replace(/[ \t\xa0]+/g, ' ') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -/** - * Next.js app-router pages embed their data in - * self.__next_f.push([1,"<escaped payload>"])</script> - * scripts. Decode every payload into one searchable text blob. - * The closing tag match tolerates an optional semicolon / whitespace - * (some Next.js versions emit `);</script>`), so chunks are never - * silently skipped due to formatting. - */ -function extractFlightText(html: string): string { - const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)\s*;?\s*<\/script>/g; - let out = ''; - let m: RegExpExecArray | null; - while ((m = re.exec(html)) !== null) { - try { - out += JSON.parse('"' + m[1] + '"'); - } catch { - /* skip malformed chunk */ - } - } - return out; -} - -/** - * Slice a JS string by UTF-8 byte offsets (the flight protocol's `T<hex>` - * length prefix counts UTF-8 bytes of the row payload, not UTF-16 chars). - */ -function sliceUtf8Bytes(s: string, start: number, byteLen: number): string { - let bytes = 0; - let i = start; - while (i < s.length && bytes < byteLen) { - const code = s.charCodeAt(i); - if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { - const next = s.charCodeAt(i + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - i += 2; - bytes += 4; - continue; - } - } - bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3; - i++; - } - return s.slice(start, i); -} - -/** Inner text of one <p>...</p> block, tags stripped. */ -function paragraphText(p: string): string { - return decodeEntities(p.replace(/<[^>]+>/g, '')).trim(); -} - -function isTitleRepeat(p: string): boolean { - // A paragraph that is nothing but bold text, e.g. the repeated - // series / chapter title the site prepends to every chapter body. - const inner = p - .replace(/^<p[^>]*>/i, '') - .replace(/<\/p>$/i, '') - .trim(); - return /^<strong>[\s\S]*<\/strong>$/.test(inner); -} - -function isPromoParagraph(p: string): boolean { - // A block that carries an illustration is content, never promo — even - // when it has no text of its own (e.g. <p><div><img></div></p>). - if (/<img[\s>]/i.test(p)) return false; - const t = paragraphText(p).toLowerCase(); - if (!t || t === '= = =') return true; - if (t.indexOf('we tried translations') !== -1) return true; - if (t.indexOf('dsc.gg') !== -1 || t.indexOf('join our discord') !== -1) - return true; - return false; -} - -/** Parse the /query API response (catalog + search share the shape). */ -function parseQueryResults(jsonText: string): { - items: NovelCard[]; - lastPage: number; -} { - const root = safeJson(jsonText); - const items: NovelCard[] = []; - let lastPage = 1; - if (isRecord(root)) { - const meta = root.meta; - if (isRecord(meta) && typeof meta.last_page === 'number') - lastPage = meta.last_page; - const data = Array.isArray(root.data) ? root.data : []; - for (const it of data) { - if (!isRecord(it)) continue; - if (it.series_type && it.series_type !== 'Novel') continue; - const slug = str(it.series_slug).trim(); - const title = str(it.title).trim(); - if (!slug || !title) continue; - items.push({ - slug, - title: decodeEntities(title), - cover: str(it.thumbnail), - }); - } - } - return { items, lastPage }; -} - -/** Parse the /series/{slug} API response. */ -function parseSeriesDetail(jsonText: string): NovelDetails | null { - const s = safeJson(jsonText); - if (!isRecord(s) || typeof s.id !== 'number') return null; - const tags = Array.isArray(s.tags) ? s.tags : []; - return { - id: s.id, - name: decodeEntities(str(s.title)), - author: decodeEntities(str(s.author)), - genres: tags - .map(t => (isRecord(t) ? decodeEntities(str(t.name)) : '')) - .filter(g => g.length > 0), - status: str(s.status), - cover: str(s.thumbnail), - summary: stripHtml(str(s.description)), - }; -} - -/** - * Parse one page of the /chapters/{seriesId} API response. - * Pass locked=true for pages from the /paid endpoint. - */ -function parseChapterList( - jsonText: string, - locked = false, -): { items: ChapterInfo[]; lastPage: number } { - const root = safeJson(jsonText); - const items: ChapterInfo[] = []; - let lastPage = 1; - if (isRecord(root)) { - const meta = root.meta; - if (isRecord(meta) && typeof meta.last_page === 'number') - lastPage = meta.last_page; - const data = Array.isArray(root.data) ? root.data : []; - for (const c of data) { - if (!isRecord(c)) continue; - const slug = str(c.chapter_slug).trim(); - const name = str(c.chapter_name).trim(); - if (!slug || !name) continue; - const title = str(c.chapter_title).trim(); - const idx = parseFloat(str(c.index)); - items.push({ - slug, - name: title ? name + ': ' + decodeEntities(title) : name, - number: isNaN(idx) ? 0 : idx, - publishedAt: str(c.created_at), - locked, - }); - } - } - return { items, lastPage }; -} - -/** - * The display name for a chapter in the app. Locked (paywalled) chapters - * get a lock prefix so readers can see which ones are premium before - * tapping them. LNReader sorts by chapterNumber, so the prefix never - * affects chapter order. - */ -function chapterDisplayName(c: ChapterInfo): string { - return c.locked ? '🔒 ' + c.name : c.name; -} - -function proxiedImageUrl(url: string, width: number): string { - const bare = url.replace(/^https?:\/\//i, ''); - return ( - 'https://images.weserv.nl/?url=' + - encodeURIComponent(bare) + - '&w=' + - width + - '&q=80&output=webp' - ); -} - -function shrinkIllustrations(html: string): string { - return html.replace( - /<img\b([^>]*?)\bsrc="(https?:\/\/media\.reaperscans\.net\/[^"]+)"([^>]*?)>/gi, - (_m, pre, src, post) => - '<img' + pre + ' src="' + proxiedImageUrl(src, 800) + '"' + post + '>', - ); -} - -/** - * Route a cover through a fast image proxy at a list-friendly size. - * The site's own covers are up to ~1 MB (they load painfully slowly in - * the app's novel list) and the CDN offers no smaller variant, so we - * request a 400px-wide webp instead. Non-URL values pass through. - */ -function coverUrl(thumbnail: string): string { - const t = (thumbnail || '').trim(); - if (!/^https?:\/\//i.test(t)) return t; - return proxiedImageUrl(t, 400); -} - -type ChapterContentResult = - | { status: 'ok'; html: string } - | { status: 'premium' | 'notfound' | 'empty' }; - -/** - * Extract the chapter body from a chapter page's HTML. - * - * The page is a Next.js app-router page: the chapter record carries - * `"chapter_content":"$<rowId>"` and the body HTML lives in the flight - * row `<rowId>:T<hex>,` that follows, where <hex> is the exact UTF-8 byte - * length of the payload. Slicing by that byte count is required — a - * "next row" lookahead overshoots into flight metadata and corrupts the - * HTML. Returns the cleaned HTML, or a non-ok status for locked / - * missing / unparseable chapters. - */ -function parseChapterContent(html: string): ChapterContentResult { - if (/this chapter is premium!/i.test(html)) return { status: 'premium' }; - // Only the real <title> element counts for the 404 check: Next.js flight - // data always embeds a notFound template containing similar wording. - const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html); - if (title && /^\s*404/i.test(title[1])) return { status: 'notfound' }; - - const flight = extractFlightText(html); - // chapter_content is either a flight-row reference ("$<rowId>") or the - // chapter HTML inline (gallery/illustration chapters). The value is a - // JSON string, so internal quotes arrive escaped. - const contentM = /"chapter_content":"((?:[^"\\]|\\.)*)"/.exec(flight); - if (!contentM) return { status: 'empty' }; - let raw: string; - try { - raw = JSON.parse('"' + contentM[1] + '"'); - } catch { - return { status: 'empty' }; - } - - let payload: string; - const rowRef = /^\$([0-9a-z]{1,4})$/.exec(raw); - if (rowRef) { - // The row looks like `<rowId>:T<hex>,<payload>` where <hex> is the exact - // UTF-8 byte length of the payload. Respecting it is the only reliable - // end boundary: flight metadata follows the payload on the same line, - // so a "next row" lookahead overshoots. - const rowRe = new RegExp( - '\\n' + - rowRef[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + - ':T([0-9a-f]+),', - ); - const row = rowRe.exec(flight); - if (!row) return { status: 'empty' }; - const byteLen = parseInt(row[1], 16); - if (!(byteLen > 0)) return { status: 'empty' }; - const payloadStart = row.index + row[0].length; - payload = sliceUtf8Bytes(flight, payloadStart, byteLen); - } else if (/^\s*</.test(raw)) { - payload = raw; - } else { - return { status: 'empty' }; - } - if (!payload) return { status: 'empty' }; - - // Paragraph breaks inside the payload are literal \r\n / \n sequences. - const body = payload - .replace(/\\r\\n/g, '\n') - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\n') - .trim(); - if (!body) return { status: 'empty' }; - - // Split into top-level blocks — paragraphs, headings, figures and - // standalone images, in document order — and trim the site's promo - // header / footer (banner, series/chapter title repeats, discord plug). - const blocks = body.match( - /<p[\s\S]*?<\/p>|<h[1-6][\s\S]*?<\/h[1-6]>|<figure[\s\S]*?<\/figure>|<img[^>]*>/gi, - ) || [body]; - let start = 0; - let end = blocks.length; - const isEdgeJunk = (p: string) => isPromoParagraph(p) || isTitleRepeat(p); - while (start < end && isEdgeJunk(blocks[start])) start++; - while (end > start && isEdgeJunk(blocks[end - 1])) end--; - const cleaned = blocks.slice(start, end).join('\n'); - if (!paragraphText(cleaned)) return { status: 'empty' }; - return { status: 'ok', html: shrinkIllustrations(cleaned) }; -} - -function mapStatus(s: string): string { - if (s === 'Ongoing') return NovelStatus.Ongoing; - if (s === 'Completed') return NovelStatus.Completed; - if (s === 'Hiatus' || s === 'On Hiatus') return NovelStatus.OnHiatus; - if (s === 'Cancelled' || s === 'Dropped') return NovelStatus.Cancelled; - return NovelStatus.Unknown; -} - -// --- Catalog filters ---------------------------------------------------- -// The API honors `status` but ignores `tags` and `sort`, so the filter -// menu offers status only. -const STATUS_FILTER_OPTIONS = [ - { label: 'All', value: 'all' }, - { label: 'Ongoing', value: 'Ongoing' }, - { label: 'Completed', value: 'Completed' }, - { label: 'Dropped', value: 'Dropped' }, - { label: 'Canceled', value: 'Canceled' }, -] as const; - -/** - * Pull a plain string value out of the app's filter payload, which may be - * the raw string or a { type, value } wrapper object. - */ -function extractFilterValue(filters: unknown, key: string): string { - if (!filters || typeof filters !== 'object') return ''; - const f = (filters as Record<string, unknown>)[key]; - if (f === null || f === undefined) return ''; - const v = - typeof f === 'object' && 'value' in f ? (f as { value: unknown }).value : f; - return typeof v === 'string' ? v : ''; -} - -class WeTriedTLS implements Plugin.PluginBase { - id = 'wetriedtls'; - name = 'We Tried TLS'; - icon = 'src/en/wetriedtls/icon.png'; - site = SITE; - version = '1.0.4'; - - filters = { - status: { - type: FilterTypes.Picker, - label: 'Status', - value: 'all', - options: STATUS_FILTER_OPTIONS, - }, - } satisfies Filters; - - async popularNovels( - pageNo: number, - { filters }: Plugin.PopularNovelsOptions<typeof this.filters>, - ): Promise<Plugin.NovelItem[]> { - const status = extractFilterValue(filters, 'status'); - const page = parseQueryResults(await fetchText(catalogUrl(pageNo, status))); - if (pageNo > page.lastPage) return []; - return page.items.map(n => ({ - name: n.title, - path: n.slug, - cover: coverUrl(n.cover), - })); - } - - async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { - const slug = novelPath.split('/').filter(Boolean).pop() || ''; - const detail = parseSeriesDetail(await fetchText(API + '/series/' + slug)); - if (!detail) throw new Error('Could not load novel details'); - - // The chapter list is paginated (500 per page keeps it to ~2 - // requests even for the longest series). Free chapters come from - // /chapters/{id} and paywalled chapters from /chapters/{id}/paid; - // the two are merged so locked chapters show up with a lock prefix. - // Opening a locked chapter shows a notice: it needs a paid - // subscription on the website and cannot be read here. - const all: ChapterInfo[] = []; - let pageNo = 1; - let lastPage = 1; - do { - const page = parseChapterList( - await fetchText( - API + - '/chapters/' + - detail.id + - '?page=' + - pageNo + - '&perPage=500&order=asc', - ), - ); - lastPage = page.lastPage; - for (const c of page.items) all.push(c); - pageNo++; - } while (pageNo <= lastPage); - - // Paid chapters are a bonus, not a requirement: if this endpoint - // ever fails, the novel still loads with its free chapters. - try { - let paidPageNo = 1; - let paidLastPage = 1; - do { - const page = parseChapterList( - await fetchText( - API + - '/chapters/' + - detail.id + - '/paid?query=&page=' + - paidPageNo + - '&perPage=1000&order=asc', - ), - true, - ); - paidLastPage = page.lastPage; - for (const c of page.items) all.push(c); - paidPageNo++; - } while (paidPageNo <= paidLastPage); - } catch { - // ignore: free chapters are already collected above - } - - const seen: Record<string, boolean> = {}; - const chapters: Plugin.ChapterItem[] = []; - all - .filter(c => { - if (!c.slug || seen[c.slug]) return false; - seen[c.slug] = true; - return true; - }) - .sort((a, b) => a.number - b.number) - .forEach(c => { - chapters.push({ - name: chapterDisplayName(c), - path: slug + '/' + c.slug, - releaseTime: c.publishedAt, - chapterNumber: c.number, - }); - }); - - const novel: Plugin.SourceNovel = { - path: novelPath, - name: detail.name, - status: mapStatus(detail.status), - }; - if (detail.cover) novel.cover = coverUrl(detail.cover); - if (detail.author) novel.author = detail.author; - if (detail.genres.length) novel.genres = detail.genres.join(', '); - if (detail.summary) novel.summary = detail.summary; - novel.chapters = chapters; - return novel; - } - - async parseChapter(chapterPath: string): Promise<string> { - const result = parseChapterContent( - await fetchText(SITE + '/series/' + chapterPath), - ); - if (result.status === 'ok') return result.html; - if (result.status === 'premium') { - return ( - '<p><strong>This chapter is premium on We Tried TLS.</strong></p>' + - '<p>It requires a paid subscription on the website and cannot be read here. ' + - 'Free chapters of this novel still work.</p>' - ); - } - if (result.status === 'notfound') { - return ( - '<p><strong>This chapter is no longer available on We Tried TLS.</strong></p>' + - '<p>It may have been removed or moved. Refresh the novel to update the chapter list.</p>' - ); - } - return ( - '<p><strong>Could not load this chapter.</strong></p>' + - '<p>It may be temporarily unavailable on We Tried TLS.</p>' - ); - } - - async searchNovels( - searchTerm: string, - pageNo: number, - ): Promise<Plugin.NovelItem[]> { - const page = parseQueryResults( - await fetchText( - API + - '/query?adult=true&query_string=' + - encodeURIComponent(searchTerm) + - '&page=' + - pageNo, - ), - ); - if (pageNo > page.lastPage) return []; - return page.items.map(n => ({ - name: n.title, - path: n.slug, - cover: coverUrl(n.cover), - })); - } - - resolveUrl = (path: string, _isNovel?: boolean): string => - SITE + '/series/' + path; -} - -export default new WeTriedTLS(); From afc7a71799d189b185b2bfc10808e5e5bdd2269c Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Mon, 28 Sep 2026 12:17:04 +0300 Subject: [PATCH 6/8] Address review findings: sanitize chapter HTML, image-only chapters, loud chapter-list failures, direct cover URLs --- plugins/english/wetriedtls.ts | 613 ++++++++++++++++++++++++++++++++++ 1 file changed, 613 insertions(+) create mode 100644 plugins/english/wetriedtls.ts diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts new file mode 100644 index 000000000..5d1218877 --- /dev/null +++ b/plugins/english/wetriedtls.ts @@ -0,0 +1,613 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; + +const API = 'https://api.wetriedtls.com'; + +/** + * Build the catalog browse URL for a page. The API honors the `status` + * query param (Ongoing / Completed / Dropped / Canceled) but ignores + * `tags` and `sort` params, so status is the only exposed filter. + * 'all' (or empty) means no status filtering. + */ +function catalogUrl(pageNo: number, status?: string): string { + let url = API + '/query?adult=true&query_string=&page=' + pageNo; + const s = (status || '').trim(); + if (s && s !== 'all') url += '&status=' + encodeURIComponent(s); + return url; +} +const SITE = 'https://wetriedtls.com'; + +type NovelCard = { + slug: string; + title: string; + cover: string; +}; + +type NovelDetails = { + id: number; + name: string; + author: string; + genres: string[]; + status: string; + cover: string; + summary: string; +}; + +type ChapterInfo = { + slug: string; + name: string; + number: number; + publishedAt: string; + /** True for chapters behind the site's paywall (from the /paid endpoint). */ + locked: boolean; +}; + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null; +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** Decode a handful of HTML entities; the site uses a small set. */ +function decodeEntities(s: string): string { + return s + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/’|‘/g, "'") + .replace(/”|“/g, '"') + .replace(/—/g, '—') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10))); +} + +/** Strip tags, collapse whitespace. */ +function stripHtml(html: string): string { + return decodeEntities(html.replace(/<[^>]+>/g, ' ')) + .replace(/[ \t\xa0]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Next.js app-router pages embed their data in + * self.__next_f.push([1,"<escaped payload>"])</script> + * scripts. Decode every payload into one searchable text blob. + * The closing tag match tolerates an optional semicolon / whitespace + * (some Next.js versions emit `);</script>`), so chunks are never + * silently skipped due to formatting. + */ +function extractFlightText(html: string): string { + const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)\s*;?\s*<\/script>/g; + let out = ''; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null) { + try { + out += JSON.parse('"' + m[1] + '"'); + } catch { + /* skip malformed chunk */ + } + } + return out; +} + +/** + * Slice a JS string by UTF-8 byte offsets (the flight protocol's `T<hex>` + * length prefix counts UTF-8 bytes of the row payload, not UTF-16 chars). + */ +function sliceUtf8Bytes(s: string, start: number, byteLen: number): string { + let bytes = 0; + let i = start; + while (i < s.length && bytes < byteLen) { + const code = s.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { + const next = s.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + i += 2; + bytes += 4; + continue; + } + } + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3; + i++; + } + return s.slice(start, i); +} + +/** Inner text of one <p>...</p> block, tags stripped. */ +function paragraphText(p: string): string { + return decodeEntities(p.replace(/<[^>]+>/g, '')).trim(); +} + +function isTitleRepeat(p: string): boolean { + // A paragraph that is nothing but bold text, e.g. the repeated + // series / chapter title the site prepends to every chapter body. + const inner = p + .replace(/^<p[^>]*>/i, '') + .replace(/<\/p>$/i, '') + .trim(); + return /^<strong>[\s\S]*<\/strong>$/.test(inner); +} + +function isPromoParagraph(p: string): boolean { + // A block that carries an illustration is content, never promo — even + // when it has no text of its own (e.g. <p><div><img></div></p>). + if (/<img[\s>]/i.test(p)) return false; + const t = paragraphText(p).toLowerCase(); + if (!t || t === '= = =') return true; + if (t.indexOf('we tried translations') !== -1) return true; + if (t.indexOf('dsc.gg') !== -1 || t.indexOf('join our discord') !== -1) + return true; + return false; +} + +/** + * Strip anything that could execute code from chapter HTML before it + * reaches the reader, which renders the HTML unsanitized: dangerous + * elements (script/iframe/object/...), event handler attributes + * (e.g. <img onerror=...>), and javascript: URLs. Everything else — + * paragraphs, headings, figures, images, inline formatting — is + * preserved as-is. + */ +function sanitizeHtml(html: string): string { + let out = html; + // Remove dangerous elements entirely, content included. + out = out.replace( + /<(script|iframe|object|embed|form|input|textarea|select|button|style|link|meta|base|noscript)[\s>][\s\S]*?<\/\1\s*>/gi, + '', + ); + out = out.replace( + /<\/?(script|iframe|object|embed|form|input|textarea|select|button|style|link|meta|base|noscript)[^>]*>/gi, + '', + ); + // Strip event handler attributes (onclick, onerror, ...). + out = out.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, ''); + // Neutralize javascript: URLs. + out = out.replace( + /\s(href|src|action)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, + (_m, _attr, val: string) => { + const unquoted = val.replace(/^['"]|['"]$/g, ''); + return /^\s*javascript:/i.test(unquoted) ? '' : _m; + }, + ); + return out; +} + +/** Parse the /query API response (catalog + search share the shape). */ +function parseQueryResults(jsonText: string): { + items: NovelCard[]; + lastPage: number; +} { + const root = safeJson(jsonText); + const items: NovelCard[] = []; + let lastPage = 1; + if (isRecord(root)) { + const meta = root.meta; + if (isRecord(meta) && typeof meta.last_page === 'number') + lastPage = meta.last_page; + const data = Array.isArray(root.data) ? root.data : []; + for (const it of data) { + if (!isRecord(it)) continue; + if (it.series_type && it.series_type !== 'Novel') continue; + const slug = str(it.series_slug).trim(); + const title = str(it.title).trim(); + if (!slug || !title) continue; + items.push({ + slug, + title: decodeEntities(title), + cover: str(it.thumbnail), + }); + } + } + return { items, lastPage }; +} + +/** Parse the /series/{slug} API response. */ +function parseSeriesDetail(jsonText: string): NovelDetails | null { + const s = safeJson(jsonText); + if (!isRecord(s) || typeof s.id !== 'number') return null; + const tags = Array.isArray(s.tags) ? s.tags : []; + return { + id: s.id, + name: decodeEntities(str(s.title)), + author: decodeEntities(str(s.author)), + genres: tags + .map(t => (isRecord(t) ? decodeEntities(str(t.name)) : '')) + .filter(g => g.length > 0), + status: str(s.status), + cover: str(s.thumbnail), + summary: stripHtml(str(s.description)), + }; +} + +/** + * Parse one page of the /chapters/{seriesId} API response. + * Pass locked=true for pages from the /paid endpoint. + */ +function parseChapterList( + jsonText: string, + locked = false, +): { items: ChapterInfo[]; lastPage: number } { + // A blank response means the request itself failed — it must never be + // treated as a valid (empty) page, or callers would mistake a failed + // fetch for the end of the list and return an incomplete chapter list + // as though it were complete. Throw so the failure is loud, not silent. + if (!jsonText || !jsonText.trim()) { + throw new Error('Empty response while fetching the chapter list'); + } + const root = safeJson(jsonText); + const items: ChapterInfo[] = []; + let lastPage = 1; + if (isRecord(root)) { + const meta = root.meta; + if (isRecord(meta) && typeof meta.last_page === 'number') + lastPage = meta.last_page; + const data = Array.isArray(root.data) ? root.data : []; + for (const c of data) { + if (!isRecord(c)) continue; + const slug = str(c.chapter_slug).trim(); + const name = str(c.chapter_name).trim(); + if (!slug || !name) continue; + const title = str(c.chapter_title).trim(); + const idx = parseFloat(str(c.index)); + items.push({ + slug, + name: title ? name + ': ' + decodeEntities(title) : name, + number: isNaN(idx) ? 0 : idx, + publishedAt: str(c.created_at), + locked, + }); + } + } + return { items, lastPage }; +} + +/** + * The display name for a chapter in the app. Locked (paywalled) chapters + * get a lock prefix so readers can see which ones are premium before + * tapping them. LNReader sorts by chapterNumber, so the prefix never + * affects chapter order. + */ +function chapterDisplayName(c: ChapterInfo): string { + return c.locked ? '🔒 ' + c.name : c.name; +} + +function proxiedImageUrl(url: string, width: number): string { + const bare = url.replace(/^https?:\/\//i, ''); + return ( + 'https://images.weserv.nl/?url=' + + encodeURIComponent(bare) + + '&w=' + + width + + '&q=80&output=webp' + ); +} + +function shrinkIllustrations(html: string): string { + return html.replace( + /<img\b([^>]*?)\bsrc="(https?:\/\/media\.reaperscans\.net\/[^"]+)"([^>]*?)>/gi, + (_m, pre, src, post) => + '<img' + pre + ' src="' + proxiedImageUrl(src, 800) + '"' + post + '>', + ); +} + +/** + * Covers are served directly from the site's CDN. An earlier version + * routed them through the images.weserv.nl proxy for smaller list + * thumbnails, but a single URL field cannot carry a fallback: if the + * proxy is blocked or down, every cover breaks while the site's own CDN + * still works. The direct URL avoids that external dependency. + * Non-URL values pass through. + */ +function coverUrl(thumbnail: string): string { + const t = (thumbnail || '').trim(); + if (!/^https?:\/\//i.test(t)) return t; + return t; +} + +type ChapterContentResult = + | { status: 'ok'; html: string } + | { status: 'premium' | 'notfound' | 'empty' }; + +/** + * Extract the chapter body from a chapter page's HTML. + * + * The page is a Next.js app-router page: the chapter record carries + * `"chapter_content":"$<rowId>"` and the body HTML lives in the flight + * row `<rowId>:T<hex>,` that follows, where <hex> is the exact UTF-8 byte + * length of the payload. Slicing by that byte count is required — a + * "next row" lookahead overshoots into flight metadata and corrupts the + * HTML. Returns the cleaned HTML, or a non-ok status for locked / + * missing / unparseable chapters. + */ +function parseChapterContent(html: string): ChapterContentResult { + if (/this chapter is premium!/i.test(html)) return { status: 'premium' }; + // Only the real <title> element counts for the 404 check: Next.js flight + // data always embeds a notFound template containing similar wording. + const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html); + if (title && /^\s*404/i.test(title[1])) return { status: 'notfound' }; + + const flight = extractFlightText(html); + // chapter_content is either a flight-row reference ("$<rowId>") or the + // chapter HTML inline (gallery/illustration chapters). The value is a + // JSON string, so internal quotes arrive escaped. + const contentM = /"chapter_content":"((?:[^"\\]|\\.)*)"/.exec(flight); + if (!contentM) return { status: 'empty' }; + let raw: string; + try { + raw = JSON.parse('"' + contentM[1] + '"'); + } catch { + return { status: 'empty' }; + } + + let payload: string; + const rowRef = /^\$([0-9a-z]{1,4})$/.exec(raw); + if (rowRef) { + // The row looks like `<rowId>:T<hex>,<payload>` where <hex> is the exact + // UTF-8 byte length of the payload. Respecting it is the only reliable + // end boundary: flight metadata follows the payload on the same line, + // so a "next row" lookahead overshoots. + const rowRe = new RegExp( + '\\n' + + rowRef[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + ':T([0-9a-f]+),', + ); + const row = rowRe.exec(flight); + if (!row) return { status: 'empty' }; + const byteLen = parseInt(row[1], 16); + if (!(byteLen > 0)) return { status: 'empty' }; + const payloadStart = row.index + row[0].length; + payload = sliceUtf8Bytes(flight, payloadStart, byteLen); + } else if (/^\s*</.test(raw)) { + payload = raw; + } else { + return { status: 'empty' }; + } + if (!payload) return { status: 'empty' }; + + // Paragraph breaks inside the payload are literal \r\n / \n sequences. + const body = payload + .replace(/\\r\\n/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\n') + .trim(); + if (!body) return { status: 'empty' }; + + // Split into top-level blocks — paragraphs, headings, figures and + // standalone images, in document order — and trim the site's promo + // header / footer (banner, series/chapter title repeats, discord plug). + const blocks = body.match( + /<p[\s\S]*?<\/p>|<h[1-6][\s\S]*?<\/h[1-6]>|<figure[\s\S]*?<\/figure>|<img[^>]*>/gi, + ) || [body]; + let start = 0; + let end = blocks.length; + const isEdgeJunk = (p: string) => isPromoParagraph(p) || isTitleRepeat(p); + while (start < end && isEdgeJunk(blocks[start])) start++; + while (end > start && isEdgeJunk(blocks[end - 1])) end--; + const cleaned = blocks.slice(start, end).join('\n'); + // An image-only chapter (illustrations with no text) is still real + // content — it must not be reported as empty. + if (!paragraphText(cleaned) && !/<img[\s>]/i.test(cleaned)) + return { status: 'empty' }; + // The reader renders this HTML unsanitized, so strip anything that + // could run code (event handlers, scripts, javascript: URLs) first. + return { status: 'ok', html: sanitizeHtml(shrinkIllustrations(cleaned)) }; +} + +function mapStatus(s: string): string { + if (s === 'Ongoing') return NovelStatus.Ongoing; + if (s === 'Completed') return NovelStatus.Completed; + if (s === 'Hiatus' || s === 'On Hiatus') return NovelStatus.OnHiatus; + if (s === 'Cancelled' || s === 'Dropped') return NovelStatus.Cancelled; + return NovelStatus.Unknown; +} + +// --- Catalog filters ---------------------------------------------------- +// The API honors `status` but ignores `tags` and `sort`, so the filter +// menu offers status only. +const STATUS_FILTER_OPTIONS = [ + { label: 'All', value: 'all' }, + { label: 'Ongoing', value: 'Ongoing' }, + { label: 'Completed', value: 'Completed' }, + { label: 'Dropped', value: 'Dropped' }, + { label: 'Canceled', value: 'Canceled' }, +] as const; + +/** + * Pull a plain string value out of the app's filter payload, which may be + * the raw string or a { type, value } wrapper object. + */ +function extractFilterValue(filters: unknown, key: string): string { + if (!filters || typeof filters !== 'object') return ''; + const f = (filters as Record<string, unknown>)[key]; + if (f === null || f === undefined) return ''; + const v = + typeof f === 'object' && 'value' in f ? (f as { value: unknown }).value : f; + return typeof v === 'string' ? v : ''; +} + +class WeTriedTLS implements Plugin.PluginBase { + id = 'wetriedtls'; + name = 'We Tried TLS'; + icon = 'src/en/wetriedtls/icon.png'; + site = SITE; + version = '1.0.5'; + + filters = { + status: { + type: FilterTypes.Picker, + label: 'Status', + value: 'all', + options: STATUS_FILTER_OPTIONS, + }, + } satisfies Filters; + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions<typeof this.filters>, + ): Promise<Plugin.NovelItem[]> { + const status = extractFilterValue(filters, 'status'); + const page = parseQueryResults(await fetchText(catalogUrl(pageNo, status))); + if (pageNo > page.lastPage) return []; + return page.items.map(n => ({ + name: n.title, + path: n.slug, + cover: coverUrl(n.cover), + })); + } + + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { + const slug = novelPath.split('/').filter(Boolean).pop() || ''; + const detail = parseSeriesDetail(await fetchText(API + '/series/' + slug)); + if (!detail) throw new Error('Could not load novel details'); + + // The chapter list is paginated (500 per page keeps it to ~2 + // requests even for the longest series). Free chapters come from + // /chapters/{id} and paywalled chapters from /chapters/{id}/paid; + // the two are merged so locked chapters show up with a lock prefix. + // Opening a locked chapter shows a notice: it needs a paid + // subscription on the website and cannot be read here. + const all: ChapterInfo[] = []; + let pageNo = 1; + let lastPage = 1; + do { + const json = await fetchText( + API + + '/chapters/' + + detail.id + + '?page=' + + pageNo + + '&perPage=500&order=asc', + ); + // The official fetchText returns '' when the request fails. That must + // never be treated as a valid page: parseChapterList would default + // lastPage to 1 and parseNovel would return an incomplete chapter + // list as though it were complete. Fail loudly instead. + if (!json || !json.trim()) + throw new Error('Failed to load the chapter list (page ' + pageNo + ')'); + const page = parseChapterList(json); + lastPage = page.lastPage; + for (const c of page.items) all.push(c); + pageNo++; + } while (pageNo <= lastPage); + + // Paid chapters are a bonus, not a requirement: if this endpoint + // ever fails, the novel still loads with its free chapters. + try { + let paidPageNo = 1; + let paidLastPage = 1; + do { + const page = parseChapterList( + await fetchText( + API + + '/chapters/' + + detail.id + + '/paid?query=&page=' + + paidPageNo + + '&perPage=1000&order=asc', + ), + true, + ); + paidLastPage = page.lastPage; + for (const c of page.items) all.push(c); + paidPageNo++; + } while (paidPageNo <= paidLastPage); + } catch { + // ignore: free chapters are already collected above + } + + const seen: Record<string, boolean> = {}; + const chapters: Plugin.ChapterItem[] = []; + all + .filter(c => { + if (!c.slug || seen[c.slug]) return false; + seen[c.slug] = true; + return true; + }) + .sort((a, b) => a.number - b.number) + .forEach(c => { + chapters.push({ + name: chapterDisplayName(c), + path: slug + '/' + c.slug, + releaseTime: c.publishedAt, + chapterNumber: c.number, + }); + }); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: detail.name, + status: mapStatus(detail.status), + }; + if (detail.cover) novel.cover = coverUrl(detail.cover); + if (detail.author) novel.author = detail.author; + if (detail.genres.length) novel.genres = detail.genres.join(', '); + if (detail.summary) novel.summary = detail.summary; + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise<string> { + const result = parseChapterContent( + await fetchText(SITE + '/series/' + chapterPath), + ); + if (result.status === 'ok') return result.html; + if (result.status === 'premium') { + return ( + '<p><strong>This chapter is premium on We Tried TLS.</strong></p>' + + '<p>It requires a paid subscription on the website and cannot be read here. ' + + 'Free chapters of this novel still work.</p>' + ); + } + if (result.status === 'notfound') { + return ( + '<p><strong>This chapter is no longer available on We Tried TLS.</strong></p>' + + '<p>It may have been removed or moved. Refresh the novel to update the chapter list.</p>' + ); + } + return ( + '<p><strong>Could not load this chapter.</strong></p>' + + '<p>It may be temporarily unavailable on We Tried TLS.</p>' + ); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise<Plugin.NovelItem[]> { + const page = parseQueryResults( + await fetchText( + API + + '/query?adult=true&query_string=' + + encodeURIComponent(searchTerm) + + '&page=' + + pageNo, + ), + ); + if (pageNo > page.lastPage) return []; + return page.items.map(n => ({ + name: n.title, + path: n.slug, + cover: coverUrl(n.cover), + })); + } + + resolveUrl = (path: string, _isNovel?: boolean): string => + SITE + '/series/' + path; +} + +export default new WeTriedTLS(); From a0345e18fee7c2b92cfb23fdf7fa15ff475a0155 Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Mon, 28 Sep 2026 13:05:48 +0300 Subject: [PATCH 7/8] Replace wetriedtls.ts with review-fix update --- plugins/english/wetriedtls.ts | 613 ---------------------------------- 1 file changed, 613 deletions(-) delete mode 100644 plugins/english/wetriedtls.ts diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts deleted file mode 100644 index 5d1218877..000000000 --- a/plugins/english/wetriedtls.ts +++ /dev/null @@ -1,613 +0,0 @@ -import { fetchText } from '@libs/fetch'; -import { Plugin } from '@/types/plugin'; -import { NovelStatus } from '@libs/novelStatus'; -import { Filters, FilterTypes } from '@libs/filterInputs'; - -const API = 'https://api.wetriedtls.com'; - -/** - * Build the catalog browse URL for a page. The API honors the `status` - * query param (Ongoing / Completed / Dropped / Canceled) but ignores - * `tags` and `sort` params, so status is the only exposed filter. - * 'all' (or empty) means no status filtering. - */ -function catalogUrl(pageNo: number, status?: string): string { - let url = API + '/query?adult=true&query_string=&page=' + pageNo; - const s = (status || '').trim(); - if (s && s !== 'all') url += '&status=' + encodeURIComponent(s); - return url; -} -const SITE = 'https://wetriedtls.com'; - -type NovelCard = { - slug: string; - title: string; - cover: string; -}; - -type NovelDetails = { - id: number; - name: string; - author: string; - genres: string[]; - status: string; - cover: string; - summary: string; -}; - -type ChapterInfo = { - slug: string; - name: string; - number: number; - publishedAt: string; - /** True for chapters behind the site's paywall (from the /paid endpoint). */ - locked: boolean; -}; - -function safeJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return undefined; - } -} - -function isRecord(v: unknown): v is Record<string, unknown> { - return typeof v === 'object' && v !== null; -} - -function str(v: unknown): string { - return typeof v === 'string' ? v : ''; -} - -/** Decode a handful of HTML entities; the site uses a small set. */ -function decodeEntities(s: string): string { - return s - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/�?39;/g, "'") - .replace(/’|‘/g, "'") - .replace(/”|“/g, '"') - .replace(/—/g, '—') - .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10))); -} - -/** Strip tags, collapse whitespace. */ -function stripHtml(html: string): string { - return decodeEntities(html.replace(/<[^>]+>/g, ' ')) - .replace(/[ \t\xa0]+/g, ' ') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -/** - * Next.js app-router pages embed their data in - * self.__next_f.push([1,"<escaped payload>"])</script> - * scripts. Decode every payload into one searchable text blob. - * The closing tag match tolerates an optional semicolon / whitespace - * (some Next.js versions emit `);</script>`), so chunks are never - * silently skipped due to formatting. - */ -function extractFlightText(html: string): string { - const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)\s*;?\s*<\/script>/g; - let out = ''; - let m: RegExpExecArray | null; - while ((m = re.exec(html)) !== null) { - try { - out += JSON.parse('"' + m[1] + '"'); - } catch { - /* skip malformed chunk */ - } - } - return out; -} - -/** - * Slice a JS string by UTF-8 byte offsets (the flight protocol's `T<hex>` - * length prefix counts UTF-8 bytes of the row payload, not UTF-16 chars). - */ -function sliceUtf8Bytes(s: string, start: number, byteLen: number): string { - let bytes = 0; - let i = start; - while (i < s.length && bytes < byteLen) { - const code = s.charCodeAt(i); - if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { - const next = s.charCodeAt(i + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - i += 2; - bytes += 4; - continue; - } - } - bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3; - i++; - } - return s.slice(start, i); -} - -/** Inner text of one <p>...</p> block, tags stripped. */ -function paragraphText(p: string): string { - return decodeEntities(p.replace(/<[^>]+>/g, '')).trim(); -} - -function isTitleRepeat(p: string): boolean { - // A paragraph that is nothing but bold text, e.g. the repeated - // series / chapter title the site prepends to every chapter body. - const inner = p - .replace(/^<p[^>]*>/i, '') - .replace(/<\/p>$/i, '') - .trim(); - return /^<strong>[\s\S]*<\/strong>$/.test(inner); -} - -function isPromoParagraph(p: string): boolean { - // A block that carries an illustration is content, never promo — even - // when it has no text of its own (e.g. <p><div><img></div></p>). - if (/<img[\s>]/i.test(p)) return false; - const t = paragraphText(p).toLowerCase(); - if (!t || t === '= = =') return true; - if (t.indexOf('we tried translations') !== -1) return true; - if (t.indexOf('dsc.gg') !== -1 || t.indexOf('join our discord') !== -1) - return true; - return false; -} - -/** - * Strip anything that could execute code from chapter HTML before it - * reaches the reader, which renders the HTML unsanitized: dangerous - * elements (script/iframe/object/...), event handler attributes - * (e.g. <img onerror=...>), and javascript: URLs. Everything else — - * paragraphs, headings, figures, images, inline formatting — is - * preserved as-is. - */ -function sanitizeHtml(html: string): string { - let out = html; - // Remove dangerous elements entirely, content included. - out = out.replace( - /<(script|iframe|object|embed|form|input|textarea|select|button|style|link|meta|base|noscript)[\s>][\s\S]*?<\/\1\s*>/gi, - '', - ); - out = out.replace( - /<\/?(script|iframe|object|embed|form|input|textarea|select|button|style|link|meta|base|noscript)[^>]*>/gi, - '', - ); - // Strip event handler attributes (onclick, onerror, ...). - out = out.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, ''); - // Neutralize javascript: URLs. - out = out.replace( - /\s(href|src|action)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, - (_m, _attr, val: string) => { - const unquoted = val.replace(/^['"]|['"]$/g, ''); - return /^\s*javascript:/i.test(unquoted) ? '' : _m; - }, - ); - return out; -} - -/** Parse the /query API response (catalog + search share the shape). */ -function parseQueryResults(jsonText: string): { - items: NovelCard[]; - lastPage: number; -} { - const root = safeJson(jsonText); - const items: NovelCard[] = []; - let lastPage = 1; - if (isRecord(root)) { - const meta = root.meta; - if (isRecord(meta) && typeof meta.last_page === 'number') - lastPage = meta.last_page; - const data = Array.isArray(root.data) ? root.data : []; - for (const it of data) { - if (!isRecord(it)) continue; - if (it.series_type && it.series_type !== 'Novel') continue; - const slug = str(it.series_slug).trim(); - const title = str(it.title).trim(); - if (!slug || !title) continue; - items.push({ - slug, - title: decodeEntities(title), - cover: str(it.thumbnail), - }); - } - } - return { items, lastPage }; -} - -/** Parse the /series/{slug} API response. */ -function parseSeriesDetail(jsonText: string): NovelDetails | null { - const s = safeJson(jsonText); - if (!isRecord(s) || typeof s.id !== 'number') return null; - const tags = Array.isArray(s.tags) ? s.tags : []; - return { - id: s.id, - name: decodeEntities(str(s.title)), - author: decodeEntities(str(s.author)), - genres: tags - .map(t => (isRecord(t) ? decodeEntities(str(t.name)) : '')) - .filter(g => g.length > 0), - status: str(s.status), - cover: str(s.thumbnail), - summary: stripHtml(str(s.description)), - }; -} - -/** - * Parse one page of the /chapters/{seriesId} API response. - * Pass locked=true for pages from the /paid endpoint. - */ -function parseChapterList( - jsonText: string, - locked = false, -): { items: ChapterInfo[]; lastPage: number } { - // A blank response means the request itself failed — it must never be - // treated as a valid (empty) page, or callers would mistake a failed - // fetch for the end of the list and return an incomplete chapter list - // as though it were complete. Throw so the failure is loud, not silent. - if (!jsonText || !jsonText.trim()) { - throw new Error('Empty response while fetching the chapter list'); - } - const root = safeJson(jsonText); - const items: ChapterInfo[] = []; - let lastPage = 1; - if (isRecord(root)) { - const meta = root.meta; - if (isRecord(meta) && typeof meta.last_page === 'number') - lastPage = meta.last_page; - const data = Array.isArray(root.data) ? root.data : []; - for (const c of data) { - if (!isRecord(c)) continue; - const slug = str(c.chapter_slug).trim(); - const name = str(c.chapter_name).trim(); - if (!slug || !name) continue; - const title = str(c.chapter_title).trim(); - const idx = parseFloat(str(c.index)); - items.push({ - slug, - name: title ? name + ': ' + decodeEntities(title) : name, - number: isNaN(idx) ? 0 : idx, - publishedAt: str(c.created_at), - locked, - }); - } - } - return { items, lastPage }; -} - -/** - * The display name for a chapter in the app. Locked (paywalled) chapters - * get a lock prefix so readers can see which ones are premium before - * tapping them. LNReader sorts by chapterNumber, so the prefix never - * affects chapter order. - */ -function chapterDisplayName(c: ChapterInfo): string { - return c.locked ? '🔒 ' + c.name : c.name; -} - -function proxiedImageUrl(url: string, width: number): string { - const bare = url.replace(/^https?:\/\//i, ''); - return ( - 'https://images.weserv.nl/?url=' + - encodeURIComponent(bare) + - '&w=' + - width + - '&q=80&output=webp' - ); -} - -function shrinkIllustrations(html: string): string { - return html.replace( - /<img\b([^>]*?)\bsrc="(https?:\/\/media\.reaperscans\.net\/[^"]+)"([^>]*?)>/gi, - (_m, pre, src, post) => - '<img' + pre + ' src="' + proxiedImageUrl(src, 800) + '"' + post + '>', - ); -} - -/** - * Covers are served directly from the site's CDN. An earlier version - * routed them through the images.weserv.nl proxy for smaller list - * thumbnails, but a single URL field cannot carry a fallback: if the - * proxy is blocked or down, every cover breaks while the site's own CDN - * still works. The direct URL avoids that external dependency. - * Non-URL values pass through. - */ -function coverUrl(thumbnail: string): string { - const t = (thumbnail || '').trim(); - if (!/^https?:\/\//i.test(t)) return t; - return t; -} - -type ChapterContentResult = - | { status: 'ok'; html: string } - | { status: 'premium' | 'notfound' | 'empty' }; - -/** - * Extract the chapter body from a chapter page's HTML. - * - * The page is a Next.js app-router page: the chapter record carries - * `"chapter_content":"$<rowId>"` and the body HTML lives in the flight - * row `<rowId>:T<hex>,` that follows, where <hex> is the exact UTF-8 byte - * length of the payload. Slicing by that byte count is required — a - * "next row" lookahead overshoots into flight metadata and corrupts the - * HTML. Returns the cleaned HTML, or a non-ok status for locked / - * missing / unparseable chapters. - */ -function parseChapterContent(html: string): ChapterContentResult { - if (/this chapter is premium!/i.test(html)) return { status: 'premium' }; - // Only the real <title> element counts for the 404 check: Next.js flight - // data always embeds a notFound template containing similar wording. - const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html); - if (title && /^\s*404/i.test(title[1])) return { status: 'notfound' }; - - const flight = extractFlightText(html); - // chapter_content is either a flight-row reference ("$<rowId>") or the - // chapter HTML inline (gallery/illustration chapters). The value is a - // JSON string, so internal quotes arrive escaped. - const contentM = /"chapter_content":"((?:[^"\\]|\\.)*)"/.exec(flight); - if (!contentM) return { status: 'empty' }; - let raw: string; - try { - raw = JSON.parse('"' + contentM[1] + '"'); - } catch { - return { status: 'empty' }; - } - - let payload: string; - const rowRef = /^\$([0-9a-z]{1,4})$/.exec(raw); - if (rowRef) { - // The row looks like `<rowId>:T<hex>,<payload>` where <hex> is the exact - // UTF-8 byte length of the payload. Respecting it is the only reliable - // end boundary: flight metadata follows the payload on the same line, - // so a "next row" lookahead overshoots. - const rowRe = new RegExp( - '\\n' + - rowRef[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + - ':T([0-9a-f]+),', - ); - const row = rowRe.exec(flight); - if (!row) return { status: 'empty' }; - const byteLen = parseInt(row[1], 16); - if (!(byteLen > 0)) return { status: 'empty' }; - const payloadStart = row.index + row[0].length; - payload = sliceUtf8Bytes(flight, payloadStart, byteLen); - } else if (/^\s*</.test(raw)) { - payload = raw; - } else { - return { status: 'empty' }; - } - if (!payload) return { status: 'empty' }; - - // Paragraph breaks inside the payload are literal \r\n / \n sequences. - const body = payload - .replace(/\\r\\n/g, '\n') - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\n') - .trim(); - if (!body) return { status: 'empty' }; - - // Split into top-level blocks — paragraphs, headings, figures and - // standalone images, in document order — and trim the site's promo - // header / footer (banner, series/chapter title repeats, discord plug). - const blocks = body.match( - /<p[\s\S]*?<\/p>|<h[1-6][\s\S]*?<\/h[1-6]>|<figure[\s\S]*?<\/figure>|<img[^>]*>/gi, - ) || [body]; - let start = 0; - let end = blocks.length; - const isEdgeJunk = (p: string) => isPromoParagraph(p) || isTitleRepeat(p); - while (start < end && isEdgeJunk(blocks[start])) start++; - while (end > start && isEdgeJunk(blocks[end - 1])) end--; - const cleaned = blocks.slice(start, end).join('\n'); - // An image-only chapter (illustrations with no text) is still real - // content — it must not be reported as empty. - if (!paragraphText(cleaned) && !/<img[\s>]/i.test(cleaned)) - return { status: 'empty' }; - // The reader renders this HTML unsanitized, so strip anything that - // could run code (event handlers, scripts, javascript: URLs) first. - return { status: 'ok', html: sanitizeHtml(shrinkIllustrations(cleaned)) }; -} - -function mapStatus(s: string): string { - if (s === 'Ongoing') return NovelStatus.Ongoing; - if (s === 'Completed') return NovelStatus.Completed; - if (s === 'Hiatus' || s === 'On Hiatus') return NovelStatus.OnHiatus; - if (s === 'Cancelled' || s === 'Dropped') return NovelStatus.Cancelled; - return NovelStatus.Unknown; -} - -// --- Catalog filters ---------------------------------------------------- -// The API honors `status` but ignores `tags` and `sort`, so the filter -// menu offers status only. -const STATUS_FILTER_OPTIONS = [ - { label: 'All', value: 'all' }, - { label: 'Ongoing', value: 'Ongoing' }, - { label: 'Completed', value: 'Completed' }, - { label: 'Dropped', value: 'Dropped' }, - { label: 'Canceled', value: 'Canceled' }, -] as const; - -/** - * Pull a plain string value out of the app's filter payload, which may be - * the raw string or a { type, value } wrapper object. - */ -function extractFilterValue(filters: unknown, key: string): string { - if (!filters || typeof filters !== 'object') return ''; - const f = (filters as Record<string, unknown>)[key]; - if (f === null || f === undefined) return ''; - const v = - typeof f === 'object' && 'value' in f ? (f as { value: unknown }).value : f; - return typeof v === 'string' ? v : ''; -} - -class WeTriedTLS implements Plugin.PluginBase { - id = 'wetriedtls'; - name = 'We Tried TLS'; - icon = 'src/en/wetriedtls/icon.png'; - site = SITE; - version = '1.0.5'; - - filters = { - status: { - type: FilterTypes.Picker, - label: 'Status', - value: 'all', - options: STATUS_FILTER_OPTIONS, - }, - } satisfies Filters; - - async popularNovels( - pageNo: number, - { filters }: Plugin.PopularNovelsOptions<typeof this.filters>, - ): Promise<Plugin.NovelItem[]> { - const status = extractFilterValue(filters, 'status'); - const page = parseQueryResults(await fetchText(catalogUrl(pageNo, status))); - if (pageNo > page.lastPage) return []; - return page.items.map(n => ({ - name: n.title, - path: n.slug, - cover: coverUrl(n.cover), - })); - } - - async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { - const slug = novelPath.split('/').filter(Boolean).pop() || ''; - const detail = parseSeriesDetail(await fetchText(API + '/series/' + slug)); - if (!detail) throw new Error('Could not load novel details'); - - // The chapter list is paginated (500 per page keeps it to ~2 - // requests even for the longest series). Free chapters come from - // /chapters/{id} and paywalled chapters from /chapters/{id}/paid; - // the two are merged so locked chapters show up with a lock prefix. - // Opening a locked chapter shows a notice: it needs a paid - // subscription on the website and cannot be read here. - const all: ChapterInfo[] = []; - let pageNo = 1; - let lastPage = 1; - do { - const json = await fetchText( - API + - '/chapters/' + - detail.id + - '?page=' + - pageNo + - '&perPage=500&order=asc', - ); - // The official fetchText returns '' when the request fails. That must - // never be treated as a valid page: parseChapterList would default - // lastPage to 1 and parseNovel would return an incomplete chapter - // list as though it were complete. Fail loudly instead. - if (!json || !json.trim()) - throw new Error('Failed to load the chapter list (page ' + pageNo + ')'); - const page = parseChapterList(json); - lastPage = page.lastPage; - for (const c of page.items) all.push(c); - pageNo++; - } while (pageNo <= lastPage); - - // Paid chapters are a bonus, not a requirement: if this endpoint - // ever fails, the novel still loads with its free chapters. - try { - let paidPageNo = 1; - let paidLastPage = 1; - do { - const page = parseChapterList( - await fetchText( - API + - '/chapters/' + - detail.id + - '/paid?query=&page=' + - paidPageNo + - '&perPage=1000&order=asc', - ), - true, - ); - paidLastPage = page.lastPage; - for (const c of page.items) all.push(c); - paidPageNo++; - } while (paidPageNo <= paidLastPage); - } catch { - // ignore: free chapters are already collected above - } - - const seen: Record<string, boolean> = {}; - const chapters: Plugin.ChapterItem[] = []; - all - .filter(c => { - if (!c.slug || seen[c.slug]) return false; - seen[c.slug] = true; - return true; - }) - .sort((a, b) => a.number - b.number) - .forEach(c => { - chapters.push({ - name: chapterDisplayName(c), - path: slug + '/' + c.slug, - releaseTime: c.publishedAt, - chapterNumber: c.number, - }); - }); - - const novel: Plugin.SourceNovel = { - path: novelPath, - name: detail.name, - status: mapStatus(detail.status), - }; - if (detail.cover) novel.cover = coverUrl(detail.cover); - if (detail.author) novel.author = detail.author; - if (detail.genres.length) novel.genres = detail.genres.join(', '); - if (detail.summary) novel.summary = detail.summary; - novel.chapters = chapters; - return novel; - } - - async parseChapter(chapterPath: string): Promise<string> { - const result = parseChapterContent( - await fetchText(SITE + '/series/' + chapterPath), - ); - if (result.status === 'ok') return result.html; - if (result.status === 'premium') { - return ( - '<p><strong>This chapter is premium on We Tried TLS.</strong></p>' + - '<p>It requires a paid subscription on the website and cannot be read here. ' + - 'Free chapters of this novel still work.</p>' - ); - } - if (result.status === 'notfound') { - return ( - '<p><strong>This chapter is no longer available on We Tried TLS.</strong></p>' + - '<p>It may have been removed or moved. Refresh the novel to update the chapter list.</p>' - ); - } - return ( - '<p><strong>Could not load this chapter.</strong></p>' + - '<p>It may be temporarily unavailable on We Tried TLS.</p>' - ); - } - - async searchNovels( - searchTerm: string, - pageNo: number, - ): Promise<Plugin.NovelItem[]> { - const page = parseQueryResults( - await fetchText( - API + - '/query?adult=true&query_string=' + - encodeURIComponent(searchTerm) + - '&page=' + - pageNo, - ), - ); - if (pageNo > page.lastPage) return []; - return page.items.map(n => ({ - name: n.title, - path: n.slug, - cover: coverUrl(n.cover), - })); - } - - resolveUrl = (path: string, _isNovel?: boolean): string => - SITE + '/series/' + path; -} - -export default new WeTriedTLS(); From 31435c18935267d70ef9072ca42d7d7d09841281 Mon Sep 17 00:00:00 2001 From: RevDev <robn77josh@gmail.com> Date: Mon, 28 Sep 2026 13:06:08 +0300 Subject: [PATCH 8/8] Address second-round review findings in We Tried TLS plugin --- plugins/english/wetriedtls.ts | 711 ++++++++++++++++++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 plugins/english/wetriedtls.ts diff --git a/plugins/english/wetriedtls.ts b/plugins/english/wetriedtls.ts new file mode 100644 index 000000000..7513de762 --- /dev/null +++ b/plugins/english/wetriedtls.ts @@ -0,0 +1,711 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; + +const API = 'https://api.wetriedtls.com'; + +/** + * Build the catalog browse URL for a page. The API honors the `status` + * query param (Ongoing / Completed / Dropped / Canceled) but ignores + * `tags` and `sort` params, so status is the only exposed filter. + * 'all' (or empty) means no status filtering. + */ +function catalogUrl(pageNo: number, status?: string): string { + let url = API + '/query?adult=true&query_string=&page=' + pageNo; + const s = (status || '').trim(); + if (s && s !== 'all') url += '&status=' + encodeURIComponent(s); + return url; +} +const SITE = 'https://wetriedtls.com'; + +type NovelCard = { + slug: string; + title: string; + cover: string; +}; + +type NovelDetails = { + id: number; + name: string; + author: string; + genres: string[]; + status: string; + cover: string; + summary: string; +}; + +type ChapterInfo = { + slug: string; + name: string; + number: number; + publishedAt: string; + /** True for chapters behind the site's paywall (from the /paid endpoint). */ + locked: boolean; +}; + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null; +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** Decode a handful of HTML entities; the site uses a small set. */ +function decodeEntities(s: string): string { + return s + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/�?39;/g, "'") + .replace(/’|‘/g, "'") + .replace(/”|“/g, '"') + .replace(/—/g, '—') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10))); +} + +/** Strip tags, collapse whitespace. */ +function stripHtml(html: string): string { + return decodeEntities(html.replace(/<[^>]+>/g, ' ')) + .replace(/[ \t\xa0]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Next.js app-router pages embed their data in + * self.__next_f.push([1,"<escaped payload>"])</script> + * scripts. Decode every payload into one searchable text blob. + * The closing tag match tolerates an optional semicolon / whitespace + * (some Next.js versions emit `);</script>`), so chunks are never + * silently skipped due to formatting. + */ +function extractFlightText(html: string): string { + const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)\s*;?\s*<\/script>/g; + let out = ''; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null) { + try { + out += JSON.parse('"' + m[1] + '"'); + } catch { + /* skip malformed chunk */ + } + } + return out; +} + +/** + * Slice a JS string by UTF-8 byte offsets (the flight protocol's `T<hex>` + * length prefix counts UTF-8 bytes of the row payload, not UTF-16 chars). + */ +function sliceUtf8Bytes(s: string, start: number, byteLen: number): string { + let bytes = 0; + let i = start; + while (i < s.length && bytes < byteLen) { + const code = s.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { + const next = s.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + i += 2; + bytes += 4; + continue; + } + } + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : 3; + i++; + } + return s.slice(start, i); +} + +/** Inner text of one <p>...</p> block, tags stripped. */ +function paragraphText(p: string): string { + return decodeEntities(p.replace(/<[^>]+>/g, '')).trim(); +} + +function isTitleRepeat(p: string, knownTitles?: string[]): boolean { + // A paragraph that is nothing but bold text is the site's repeated + // series / chapter title header — but only when it actually matches a + // title the caller knows. A genuine bold-only content line (a scene + // label, a POV header) must never be stripped, so without a matching + // known title nothing is treated as a repeat. + const inner = p + .replace(/^<p[^>]*>/i, '') + .replace(/<\/p>$/i, '') + .trim(); + if (!/^<strong>[\s\S]*<\/strong>$/.test(inner)) return false; + if (!knownTitles || knownTitles.length === 0) return false; + const text = decodeEntities(inner.replace(/<[^>]+>/g, '')) + .trim() + .toLowerCase(); + return knownTitles.some(t => t.trim().toLowerCase() === text); +} + +/** + * Translation credits ("Translator: X", "Editor: Y") are site chrome, not + * story content — the site puts them in the chapter header next to the + * banner. Stripped with the rest of the header so the reader starts at + * the story. Narrow on purpose: a genuine bold content line never looks + * like this. + */ +function isCreditLine(p: string): boolean { + const t = paragraphText(p); + return /^(translator|editor|proofreader|typesetter)\s*:/i.test(t); +} + +function isPromoParagraph(p: string): boolean { + // A block that carries an illustration is content, never promo — even + // when it has no text of its own (e.g. <p><div><img></div></p>). + if (/<img[\s>]/i.test(p)) return false; + const t = paragraphText(p).toLowerCase(); + if (!t || t === '= = =') return true; + if (t.indexOf('we tried translations') !== -1) return true; + if (t.indexOf('dsc.gg') !== -1 || t.indexOf('join our discord') !== -1) + return true; + return false; +} + +/** + * Decode the HTML entities that can appear inside an attribute value — + * decimal (j), hex (j), and the named entities used to smuggle a + * scheme past a naive prefix check (:) — so the scheme test below + * sees what the browser will actually navigate to. + */ +function decodeAttrEntities(s: string): string { + return s + .replace(/&#x([0-9a-f]+);?/gi, (_m, h: string) => + String.fromCharCode(parseInt(h, 16)), + ) + .replace(/&#(\d+);?/g, (_m, n: string) => + String.fromCharCode(parseInt(n, 10)), + ) + .replace(/:?/gi, ':') + .replace(/;?/gi, ';') + .replace(/&?/gi, '&') + .replace(/<?/gi, '<') + .replace(/>?/gi, '>') + .replace(/"?/gi, '"') + .replace(/�?39;?/g, "'"); +} + +/** + * True for URLs that would execute script when followed from the reader. + * The value is entity-decoded and stripped of whitespace/control + * characters first, so javascript:, javascript:, and + * java<TAB>script:-style smuggling are all caught. + */ +function isScriptUrl(url: string): boolean { + const norm = decodeAttrEntities(url).replace( + /[\s\u0000-\u001f\u007f]+/g, + '', + ); + return /^(javascript|vbscript):/i.test(norm); +} + +/** + * Strip anything that could execute code from chapter HTML before it + * reaches the reader, which renders the HTML unsanitized: dangerous + * elements (script/iframe/object/...), event handler attributes + * (e.g. <img onerror=...>), and javascript: URLs. Everything else — + * paragraphs, headings, figures, images, inline formatting — is + * preserved as-is. + */ +function sanitizeHtml(html: string): string { + let out = html; + // Remove dangerous elements entirely, content included. + out = out.replace( + /<(script|iframe|object|embed|form|input|textarea|select|button|style|link|meta|base|noscript)[\s>][\s\S]*?<\/\1\s*>/gi, + '', + ); + out = out.replace( + /<\/?(script|iframe|object|embed|form|input|textarea|select|button|style|link|meta|base|noscript)[^>]*>/gi, + '', + ); + // Strip event handler attributes (onclick, onerror, ...). + out = out.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, ''); + // Neutralize script URLs (href/src/action). The value is entity-decoded + // before the scheme check so encoded variants can't slip through. + out = out.replace( + /\s(href|src|action)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi, + (_m, _attr, val: string) => { + const unquoted = val.replace(/^['"]|['"]$/g, ''); + return isScriptUrl(unquoted) ? '' : _m; + }, + ); + return out; +} + +/** Parse the /query API response (catalog + search share the shape). */ +function parseQueryResults(jsonText: string): { + items: NovelCard[]; + lastPage: number; +} { + const root = safeJson(jsonText); + const items: NovelCard[] = []; + let lastPage = 1; + if (isRecord(root)) { + const meta = root.meta; + if (isRecord(meta) && typeof meta.last_page === 'number') + lastPage = meta.last_page; + const data = Array.isArray(root.data) ? root.data : []; + for (const it of data) { + if (!isRecord(it)) continue; + if (it.series_type && it.series_type !== 'Novel') continue; + const slug = str(it.series_slug).trim(); + const title = str(it.title).trim(); + if (!slug || !title) continue; + items.push({ + slug, + title: decodeEntities(title), + cover: str(it.thumbnail), + }); + } + } + return { items, lastPage }; +} + +/** Parse the /series/{slug} API response. */ +function parseSeriesDetail(jsonText: string): NovelDetails | null { + const s = safeJson(jsonText); + if (!isRecord(s) || typeof s.id !== 'number') return null; + const tags = Array.isArray(s.tags) ? s.tags : []; + return { + id: s.id, + name: decodeEntities(str(s.title)), + author: decodeEntities(str(s.author)), + genres: tags + .map(t => (isRecord(t) ? decodeEntities(str(t.name)) : '')) + .filter(g => g.length > 0), + status: str(s.status), + cover: str(s.thumbnail), + summary: stripHtml(str(s.description)), + }; +} + +/** + * Parse one page of the /chapters/{seriesId} API response. + * Pass locked=true for pages from the /paid endpoint. + */ +function parseChapterList( + jsonText: string, + locked = false, +): { items: ChapterInfo[]; lastPage: number } { + // A blank response means the request itself failed — it must never be + // treated as a valid (empty) page, or callers would mistake a failed + // fetch for the end of the list and return an incomplete chapter list + // as though it were complete. Throw so the failure is loud, not silent. + if (!jsonText || !jsonText.trim()) { + throw new Error('Empty response while fetching the chapter list'); + } + const root = safeJson(jsonText); + // A non-blank but unusable response (an error page, a proxy block page, + // truncated JSON) must fail loudly too: silently returning an empty page + // here would make parseNovel stop fetching and present an incomplete + // chapter list as though it were complete. + if (!isRecord(root) || !Array.isArray(root.data)) { + throw new Error('Invalid chapter-list response (not the expected JSON)'); + } + const items: ChapterInfo[] = []; + let lastPage = 1; + const meta = root.meta; + if (isRecord(meta) && typeof meta.last_page === 'number') + lastPage = meta.last_page; + for (const c of root.data) { + if (!isRecord(c)) continue; + const slug = str(c.chapter_slug).trim(); + const name = str(c.chapter_name).trim(); + if (!slug || !name) continue; + const title = str(c.chapter_title).trim(); + const idx = parseFloat(str(c.index)); + items.push({ + slug, + name: title ? name + ': ' + decodeEntities(title) : name, + number: isNaN(idx) ? 0 : idx, + publishedAt: str(c.created_at), + locked, + }); + } + return { items, lastPage }; +} + +/** + * The display name for a chapter in the app. Locked (paywalled) chapters + * get a lock prefix so readers can see which ones are premium before + * tapping them. LNReader sorts by chapterNumber, so the prefix never + * affects chapter order. + */ +function chapterDisplayName(c: ChapterInfo): string { + return c.locked ? '🔒 ' + c.name : c.name; +} + +function proxiedImageUrl(url: string, width: number): string { + const bare = url.replace(/^https?:\/\//i, ''); + return ( + 'https://images.weserv.nl/?url=' + + encodeURIComponent(bare) + + '&w=' + + width + + '&q=80&output=webp' + ); +} + +function shrinkIllustrations(html: string): string { + return html.replace( + /<img\b([^>]*?)\bsrc="(https?:\/\/media\.reaperscans\.net\/[^"]+)"([^>]*?)>/gi, + (_m, pre, src, post) => + '<img' + pre + ' src="' + proxiedImageUrl(src, 800) + '"' + post + '>', + ); +} + +/** + * Covers are served directly from the site's CDN. An earlier version + * routed them through the images.weserv.nl proxy for smaller list + * thumbnails, but a single URL field cannot carry a fallback: if the + * proxy is blocked or down, every cover breaks while the site's own CDN + * still works. The direct URL avoids that external dependency. + * Non-URL values pass through. + */ +function coverUrl(thumbnail: string): string { + const t = (thumbnail || '').trim(); + if (!/^https?:\/\//i.test(t)) return t; + return t; +} + +type ChapterContentResult = + | { status: 'ok'; html: string } + | { status: 'premium' | 'notfound' | 'empty' }; + +/** + * Extract the chapter body from a chapter page's HTML. + * + * The page is a Next.js app-router page: the chapter record carries + * `"chapter_content":"$<rowId>"` and the body HTML lives in the flight + * row `<rowId>:T<hex>,` that follows, where <hex> is the exact UTF-8 byte + * length of the payload. Slicing by that byte count is required — a + * "next row" lookahead overshoots into flight metadata and corrupts the + * HTML. + * + * `knownTitles` (the novel title and this chapter's display name) lets the + * parser tell the site's repeated title header apart from a genuine + * bold-only content line, which must be kept. Returns the cleaned HTML, + * or a non-ok status for locked / missing / unparseable chapters. + */ +function parseChapterContent( + html: string, + knownTitles?: string[], +): ChapterContentResult { + if (/this chapter is premium!/i.test(html)) return { status: 'premium' }; + // Only the real <title> element counts for the 404 check: Next.js flight + // data always embeds a notFound template containing similar wording. + const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html); + if (title && /^\s*404/i.test(title[1])) return { status: 'notfound' }; + + const flight = extractFlightText(html); + // chapter_content is either a flight-row reference ("$<rowId>") or the + // chapter HTML inline (gallery/illustration chapters). The value is a + // JSON string, so internal quotes arrive escaped. + const contentM = /"chapter_content":"((?:[^"\\]|\\.)*)"/.exec(flight); + if (!contentM) return { status: 'empty' }; + let raw: string; + try { + raw = JSON.parse('"' + contentM[1] + '"'); + } catch { + return { status: 'empty' }; + } + + let payload: string; + const rowRef = /^\$([0-9a-z]{1,4})$/.exec(raw); + if (rowRef) { + // The row looks like `<rowId>:T<hex>,<payload>` where <hex> is the exact + // UTF-8 byte length of the payload. Respecting it is the only reliable + // end boundary: flight metadata follows the payload on the same line, + // so a "next row" lookahead overshoots. + const rowRe = new RegExp( + '\\n' + + rowRef[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + ':T([0-9a-f]+),', + ); + const row = rowRe.exec(flight); + if (!row) return { status: 'empty' }; + const byteLen = parseInt(row[1], 16); + if (!(byteLen > 0)) return { status: 'empty' }; + const payloadStart = row.index + row[0].length; + payload = sliceUtf8Bytes(flight, payloadStart, byteLen); + } else if (/^\s*</.test(raw)) { + payload = raw; + } else { + return { status: 'empty' }; + } + if (!payload) return { status: 'empty' }; + + // Paragraph breaks inside the payload are literal \r\n / \n sequences. + const body = payload + .replace(/\\r\\n/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\n') + .trim(); + if (!body) return { status: 'empty' }; + + // Split into top-level blocks in document order. Paragraphs, headings, + // figures and standalone images are recognized; anything else that + // carries text (lists, tables, blockquotes, divs) is kept too — dropping + // it would silently lose chapter content. Only the site's promo header / + // footer (banner, credits, title repeats, discord plug) is trimmed from + // the edges. + const blocks: string[] = []; + const blockRe = + /<p[\s\S]*?<\/p>|<h[1-6][\s\S]*?<\/h[1-6]>|<figure[\s\S]*?<\/figure>|<img[^>]*>/gi; + let last = 0; + let bm: RegExpExecArray | null; + while ((bm = blockRe.exec(body)) !== null) { + const gap = body.slice(last, bm.index); + if (gap.replace(/<[^>]+>/g, '').trim()) blocks.push(gap.trim()); + blocks.push(bm[0]); + last = bm.index + bm[0].length; + } + const tail = body.slice(last); + if (tail.replace(/<[^>]+>/g, '').trim()) blocks.push(tail.trim()); + if (blocks.length === 0) blocks.push(body); + + let start = 0; + let end = blocks.length; + const isEdgeJunk = (p: string) => + isPromoParagraph(p) || isCreditLine(p) || isTitleRepeat(p, knownTitles); + while (start < end && isEdgeJunk(blocks[start])) start++; + while (end > start && isEdgeJunk(blocks[end - 1])) end--; + const cleaned = blocks.slice(start, end).join('\n'); + // An image-only chapter (illustrations with no text) is still real + // content — it must not be reported as empty. + if (!paragraphText(cleaned) && !/<img[\s>]/i.test(cleaned)) + return { status: 'empty' }; + // The reader renders this HTML unsanitized, so strip anything that + // could run code (event handlers, scripts, javascript: URLs) first. + return { status: 'ok', html: sanitizeHtml(shrinkIllustrations(cleaned)) }; +} + +function mapStatus(s: string): string { + if (s === 'Ongoing') return NovelStatus.Ongoing; + if (s === 'Completed') return NovelStatus.Completed; + if (s === 'Hiatus' || s === 'On Hiatus') return NovelStatus.OnHiatus; + if (s === 'Cancelled' || s === 'Dropped') return NovelStatus.Cancelled; + return NovelStatus.Unknown; +} + +// --- Catalog filters ---------------------------------------------------- +// The API honors `status` but ignores `tags` and `sort`, so the filter +// menu offers status only. +const STATUS_FILTER_OPTIONS = [ + { label: 'All', value: 'all' }, + { label: 'Ongoing', value: 'Ongoing' }, + { label: 'Completed', value: 'Completed' }, + { label: 'Dropped', value: 'Dropped' }, + { label: 'Canceled', value: 'Canceled' }, +] as const; + +/** + * Pull a plain string value out of the app's filter payload, which may be + * the raw string or a { type, value } wrapper object. + */ +function extractFilterValue(filters: unknown, key: string): string { + if (!filters || typeof filters !== 'object') return ''; + const f = (filters as Record<string, unknown>)[key]; + if (f === null || f === undefined) return ''; + const v = + typeof f === 'object' && 'value' in f ? (f as { value: unknown }).value : f; + return typeof v === 'string' ? v : ''; +} + +class WeTriedTLS implements Plugin.PluginBase { + id = 'wetriedtls'; + name = 'We Tried TLS'; + icon = 'src/en/wetriedtls/icon.png'; + site = SITE; + version = '1.0.6'; + + // Novel/chapter titles behind each chapter path, recorded by parseNovel. + // parseChapter passes them to parseChapterContent so the site's repeated + // title header can be told apart from a genuine bold-only content line + // (which must be kept). + private chapterTitles: Record<string, string[]> = {}; + + filters = { + status: { + type: FilterTypes.Picker, + label: 'Status', + value: 'all', + options: STATUS_FILTER_OPTIONS, + }, + } satisfies Filters; + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions<typeof this.filters>, + ): Promise<Plugin.NovelItem[]> { + const status = extractFilterValue(filters, 'status'); + const page = parseQueryResults(await fetchText(catalogUrl(pageNo, status))); + if (pageNo > page.lastPage) return []; + return page.items.map(n => ({ + name: n.title, + path: n.slug, + cover: coverUrl(n.cover), + })); + } + + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { + const slug = novelPath.split('/').filter(Boolean).pop() || ''; + const detail = parseSeriesDetail(await fetchText(API + '/series/' + slug)); + if (!detail) throw new Error('Could not load novel details'); + + // The chapter list is paginated (500 per page keeps it to ~2 + // requests even for the longest series). Free chapters come from + // /chapters/{id} and paywalled chapters from /chapters/{id}/paid; + // the two are merged so locked chapters show up with a lock prefix. + // Opening a locked chapter shows a notice: it needs a paid + // subscription on the website and cannot be read here. + const all: ChapterInfo[] = []; + let pageNo = 1; + let lastPage = 1; + do { + const json = await fetchText( + API + + '/chapters/' + + detail.id + + '?page=' + + pageNo + + '&perPage=500&order=asc', + ); + // The official fetchText returns '' when the request fails. That must + // never be treated as a valid page: parseChapterList would default + // lastPage to 1 and parseNovel would return an incomplete chapter + // list as though it were complete. Fail loudly instead. + if (!json || !json.trim()) + throw new Error('Failed to load the chapter list (page ' + pageNo + ')'); + const page = parseChapterList(json); + lastPage = page.lastPage; + for (const c of page.items) all.push(c); + pageNo++; + } while (pageNo <= lastPage); + + // Paid chapters are a bonus, not a requirement: if this endpoint + // ever fails, the novel still loads with its free chapters. + try { + let paidPageNo = 1; + let paidLastPage = 1; + do { + const page = parseChapterList( + await fetchText( + API + + '/chapters/' + + detail.id + + '/paid?query=&page=' + + paidPageNo + + '&perPage=1000&order=asc', + ), + true, + ); + paidLastPage = page.lastPage; + for (const c of page.items) all.push(c); + paidPageNo++; + } while (paidPageNo <= paidLastPage); + } catch { + // ignore: free chapters are already collected above + } + + const seen: Record<string, boolean> = {}; + const chapters: Plugin.ChapterItem[] = []; + all + .filter(c => { + if (!c.slug || seen[c.slug]) return false; + seen[c.slug] = true; + return true; + }) + .sort((a, b) => a.number - b.number) + .forEach(c => { + const displayName = chapterDisplayName(c); + const chapterPath = slug + '/' + c.slug; + chapters.push({ + name: displayName, + path: chapterPath, + releaseTime: c.publishedAt, + chapterNumber: c.number, + }); + this.chapterTitles[chapterPath] = [ + detail.name, + displayName.replace(/^🔒\s*/, ''), + ]; + }); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: detail.name, + status: mapStatus(detail.status), + }; + if (detail.cover) novel.cover = coverUrl(detail.cover); + if (detail.author) novel.author = detail.author; + if (detail.genres.length) novel.genres = detail.genres.join(', '); + if (detail.summary) novel.summary = detail.summary; + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise<string> { + const result = parseChapterContent( + await fetchText(SITE + '/series/' + chapterPath), + this.chapterTitles[chapterPath], + ); + if (result.status === 'ok') return result.html; + if (result.status === 'premium') { + return ( + '<p><strong>This chapter is premium on We Tried TLS.</strong></p>' + + '<p>It requires a paid subscription on the website and cannot be read here. ' + + 'Free chapters of this novel still work.</p>' + ); + } + if (result.status === 'notfound') { + return ( + '<p><strong>This chapter is no longer available on We Tried TLS.</strong></p>' + + '<p>It may have been removed or moved. Refresh the novel to update the chapter list.</p>' + ); + } + return ( + '<p><strong>Could not load this chapter.</strong></p>' + + '<p>It may be temporarily unavailable on We Tried TLS.</p>' + ); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise<Plugin.NovelItem[]> { + const page = parseQueryResults( + await fetchText( + API + + '/query?adult=true&query_string=' + + encodeURIComponent(searchTerm) + + '&page=' + + pageNo, + ), + ); + if (pageNo > page.lastPage) return []; + return page.items.map(n => ({ + name: n.title, + path: n.slug, + cover: coverUrl(n.cover), + })); + } + + resolveUrl = (path: string, _isNovel?: boolean): string => + SITE + '/series/' + path; +} + +export default new WeTriedTLS();