From 57871aa1e2507e81ad74c39f466b7f4160f81a3f Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 22:33:07 +0300 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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,