From 66af1d1b4c63fcad5addcd718f60a3032a0a477d Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 20:02:43 +0800 Subject: [PATCH 1/2] fix(english/daotekno): report the site's in-app browser block instead of a bare 403 daotekno.com answers HTTP 403 "Access Denied" to any User-Agent carrying the Android WebView '; wv' token, which is LNReader's own UA, and asks readers to open the site in Google Chrome. The plugin hid this behind a hardcoded Chrome UA that cannot reuse the WebView's Cloudflare clearance, so users only saw 'Request failed: 403'. Send the app's own User-Agent and name the cause: the site's in-app browser block, or a Cloudflare challenge that WebView can pass. --- plugins/english/daotekno.ts | 78 ++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/plugins/english/daotekno.ts b/plugins/english/daotekno.ts index a3b5b7a35..e841d2234 100644 --- a/plugins/english/daotekno.ts +++ b/plugins/english/daotekno.ts @@ -1,5 +1,5 @@ import { load as parseHTML } from 'cheerio'; -import { fetchApi, FetchInit } from '@libs/fetch'; +import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { defaultCover } from '@libs/defaultCover'; @@ -21,46 +21,45 @@ import { defaultCover } from '@libs/defaultCover'; * - Chapter page: `/series///` — text inside `#novel-content`. * * The site gates chapter pages on User-Agent: desktop UAs receive a - * "Mobile Only Content" interstitial instead of the chapter, so every - * request sends a mobile browser UA. + * "Mobile Only Content" interstitial instead of the chapter. + * + * The site also refuses app readers on purpose: any User-Agent carrying the + * Android WebView `; wv` token (LNReader's own UA and its WebView) gets an + * HTTP 403 "Access Denied" page asking readers to open the site in Google + * Chrome. The plugin sends the app's own User-Agent and reports that + * refusal instead of disguising the app as a browser. */ class DaoTekno implements Plugin.PluginBase { id = 'daotekno'; name = 'DaoTekno'; - version = '1.0.1'; + version = '1.0.2'; icon = 'src/en/daotekno/icon.png'; site = 'https://daotekno.com/'; - // Browser-like headers (important for Cloudflare-fronted sites, which - // serve a bot-check page to requests without them). The UA is a mobile - // Chrome: the site serves chapter pages only to mobile UAs. The - // sec-ch-ua/sec-fetch-* fields mirror what a Chrome fetch() sends here - // (Sec-Fetch-Mode: cors is added by fetchApi's default headers). - private headers = { - 'User-Agent': - 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.9', - 'Referer': this.site, - 'sec-ch-ua': - '"Chromium";v="126", "Not(A:Brand";v="24", "Google Chrome";v="126"', - 'sec-ch-ua-mobile': '?1', - 'sec-ch-ua-platform': '"Android"', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Dest': 'empty', - }; - // Throw (carrying the HTTP status) on a refused response so a // runner-side block is reported INCONCLUSIVE per docs/testing.md // instead of being parsed into a false empty-result FAIL. - private async fetchSite(url: string, init?: FetchInit) { - const res = await fetchApi(url, init); + private async fetchHtml(url: string): Promise { + const res = await fetchApi(url); if (!res.ok) { - throw Object.assign(new Error('Request failed: ' + res.status), { - status: res.status, - }); + let message = 'Request failed: ' + res.status; + if (res.headers.get('cf-mitigated') === 'challenge') { + message = + 'Cloudflare challenge (HTTP ' + + res.status + + '). Open daotekno.com in WebView to pass it, then retry.'; + } else if ( + res.status === 403 && + /in-app browser is not allowed/i.test(await res.text()) + ) { + message = + 'daotekno.com blocks reading in apps and in-app browsers ' + + '(HTTP 403 "Access Denied") and asks readers to open the site ' + + 'in Google Chrome instead.'; + } + throw Object.assign(new Error(message), { status: res.status }); } - return res; + return res.text(); } private parseNovelCards(html: string): Plugin.NovelItem[] { @@ -106,9 +105,7 @@ class DaoTekno implements Plugin.PluginBase { } async popularNovels(pageNo: number): Promise { - const html = await this.fetchSite(`${this.site}?page=${pageNo}`, { - headers: this.headers, - }).then(r => r.text()); + const html = await this.fetchHtml(`${this.site}?page=${pageNo}`); // Out-of-range pages are clamped to the last page (?page=11 serves // page 10), so compare the requested page against the `N / M` marker @@ -123,9 +120,7 @@ class DaoTekno implements Plugin.PluginBase { } async parseNovel(novelPath: string): Promise { - const firstHtml = await this.fetchSite(this.site + novelPath, { - headers: this.headers, - }).then(r => r.text()); + const firstHtml = await this.fetchHtml(this.site + novelPath); const $first = parseHTML(firstHtml); const novel: Plugin.SourceNovel = { @@ -142,9 +137,7 @@ class DaoTekno implements Plugin.PluginBase { const maxChapterPages = 100; while (nextHref && pagesFetched < maxChapterPages) { - const html = await this.fetchSite(this.site + novelPath + nextHref, { - headers: this.headers, - }).then(r => r.text()); + const html = await this.fetchHtml(this.site + novelPath + nextHref); const $ = parseHTML(html); chapters.push(...this.collectChapters($)); nextHref = this.nextChapterPage($); @@ -158,9 +151,7 @@ class DaoTekno implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const html = await this.fetchSite(this.site + chapterPath, { - headers: this.headers, - }).then(r => r.text()); + const html = await this.fetchHtml(this.site + chapterPath); const $ = parseHTML(html); const content = $('#novel-content'); @@ -185,10 +176,9 @@ class DaoTekno implements Plugin.PluginBase { return []; } - const html = await this.fetchSite( + const html = await this.fetchHtml( `${this.site}?q=${encodeURIComponent(searchTerm)}`, - { headers: this.headers }, - ).then(r => r.text()); + ); return this.parseNovelCards(html); } From 1f01a833774d7002d110cb8e56af71a53def6e8e Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 8 Oct 2026 20:12:10 +0800 Subject: [PATCH 2/2] no-mistakes(review): restore daotekno request headers, keep error translation --- plugins/english/daotekno.ts | 57 ++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/plugins/english/daotekno.ts b/plugins/english/daotekno.ts index e841d2234..0b2ccf594 100644 --- a/plugins/english/daotekno.ts +++ b/plugins/english/daotekno.ts @@ -1,5 +1,5 @@ import { load as parseHTML } from 'cheerio'; -import { fetchApi } from '@libs/fetch'; +import { fetchApi, FetchInit } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { defaultCover } from '@libs/defaultCover'; @@ -21,13 +21,8 @@ import { defaultCover } from '@libs/defaultCover'; * - Chapter page: `/series///` — text inside `#novel-content`. * * The site gates chapter pages on User-Agent: desktop UAs receive a - * "Mobile Only Content" interstitial instead of the chapter. - * - * The site also refuses app readers on purpose: any User-Agent carrying the - * Android WebView `; wv` token (LNReader's own UA and its WebView) gets an - * HTTP 403 "Access Denied" page asking readers to open the site in Google - * Chrome. The plugin sends the app's own User-Agent and reports that - * refusal instead of disguising the app as a browser. + * "Mobile Only Content" interstitial instead of the chapter, so every + * request sends a mobile browser UA. */ class DaoTekno implements Plugin.PluginBase { id = 'daotekno'; @@ -36,11 +31,30 @@ class DaoTekno implements Plugin.PluginBase { icon = 'src/en/daotekno/icon.png'; site = 'https://daotekno.com/'; + // Browser-like headers (important for Cloudflare-fronted sites, which + // serve a bot-check page to requests without them). The UA is a mobile + // Chrome: the site serves chapter pages only to mobile UAs. The + // sec-ch-ua/sec-fetch-* fields mirror what a Chrome fetch() sends here + // (Sec-Fetch-Mode: cors is added by fetchApi's default headers). + private headers = { + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Referer': this.site, + 'sec-ch-ua': + '"Chromium";v="126", "Not(A:Brand";v="24", "Google Chrome";v="126"', + 'sec-ch-ua-mobile': '?1', + 'sec-ch-ua-platform': '"Android"', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Dest': 'empty', + }; + // Throw (carrying the HTTP status) on a refused response so a // runner-side block is reported INCONCLUSIVE per docs/testing.md // instead of being parsed into a false empty-result FAIL. - private async fetchHtml(url: string): Promise { - const res = await fetchApi(url); + private async fetchSite(url: string, init?: FetchInit) { + const res = await fetchApi(url, init); if (!res.ok) { let message = 'Request failed: ' + res.status; if (res.headers.get('cf-mitigated') === 'challenge') { @@ -59,7 +73,7 @@ class DaoTekno implements Plugin.PluginBase { } throw Object.assign(new Error(message), { status: res.status }); } - return res.text(); + return res; } private parseNovelCards(html: string): Plugin.NovelItem[] { @@ -105,7 +119,9 @@ class DaoTekno implements Plugin.PluginBase { } async popularNovels(pageNo: number): Promise { - const html = await this.fetchHtml(`${this.site}?page=${pageNo}`); + const html = await this.fetchSite(`${this.site}?page=${pageNo}`, { + headers: this.headers, + }).then(r => r.text()); // Out-of-range pages are clamped to the last page (?page=11 serves // page 10), so compare the requested page against the `N / M` marker @@ -120,7 +136,9 @@ class DaoTekno implements Plugin.PluginBase { } async parseNovel(novelPath: string): Promise { - const firstHtml = await this.fetchHtml(this.site + novelPath); + const firstHtml = await this.fetchSite(this.site + novelPath, { + headers: this.headers, + }).then(r => r.text()); const $first = parseHTML(firstHtml); const novel: Plugin.SourceNovel = { @@ -137,7 +155,9 @@ class DaoTekno implements Plugin.PluginBase { const maxChapterPages = 100; while (nextHref && pagesFetched < maxChapterPages) { - const html = await this.fetchHtml(this.site + novelPath + nextHref); + const html = await this.fetchSite(this.site + novelPath + nextHref, { + headers: this.headers, + }).then(r => r.text()); const $ = parseHTML(html); chapters.push(...this.collectChapters($)); nextHref = this.nextChapterPage($); @@ -151,7 +171,9 @@ class DaoTekno implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const html = await this.fetchHtml(this.site + chapterPath); + const html = await this.fetchSite(this.site + chapterPath, { + headers: this.headers, + }).then(r => r.text()); const $ = parseHTML(html); const content = $('#novel-content'); @@ -176,9 +198,10 @@ class DaoTekno implements Plugin.PluginBase { return []; } - const html = await this.fetchHtml( + const html = await this.fetchSite( `${this.site}?q=${encodeURIComponent(searchTerm)}`, - ); + { headers: this.headers }, + ).then(r => r.text()); return this.parseNovelCards(html); }