From ed31a2917bc8fef840f943809490785041a8dab9 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 22 Sep 2026 13:34:06 +0800 Subject: [PATCH 1/2] feat(english): add DaoTekno source plugin for daotekno.com Add a standalone LNReader source for daotekno.com (issue #2543), a custom PHP reader serving the DaoTranslate.com catalog. Parses the paginated catalog listing, series chapter lists (merging the site's 200-chapter pages, newest-first, reversed to ascending), chapter bodies from #novel-content, and server-side search. Sends browser-like headers with a mobile Chrome User-Agent (the site gates chapter pages on UA) and throws with the HTTP status on refused responses so runner-side blocks report INCONCLUSIVE per docs/testing.md. Authored by an AI agent (pi) as an automated worker task. Co-Authored-By: AI agent pi --- plugins/english/daotekno.ts | 191 +++++++++++++++++++++++++ public/static/src/en/daotekno/icon.png | Bin 0 -> 7452 bytes 2 files changed, 191 insertions(+) create mode 100644 plugins/english/daotekno.ts create mode 100644 public/static/src/en/daotekno/icon.png diff --git a/plugins/english/daotekno.ts b/plugins/english/daotekno.ts new file mode 100644 index 000000000..c5882924f --- /dev/null +++ b/plugins/english/daotekno.ts @@ -0,0 +1,191 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi, FetchInit } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { defaultCover } from '@libs/defaultCover'; + +/** + * DaoTekno (daotekno.com) LNReader plugin + * + * daotekno.com serves the DaoTranslate.com catalog — pages are titled + * "DaoTranslate.com - Web Novel Reader" and chapter footers link to + * DaoTranslate (the connection mentioned in the plugin request). It is a + * custom PHP reader, not WordPress/Madara: + * + * - Catalog: `/?page=N` — 50 `a.novel-card.novel-item` rows per page, + * title in `.novel-title`, page marker `N / M`. + * - Search: `/?q=` — filtered server-side, same card markup, + * single page (page parameters are ignored). + * - Novel page: `/series//` — `

` title, chapter tiles in + * `#allChaptersGrid` (`.chapter-box`, newest first) split + * across `?page=N`, 200 per page. + * - 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. + */ +class DaoTekno implements Plugin.PluginBase { + id = 'daotekno'; + name = 'DaoTekno'; + version = '1.0.0'; + icon = 'src/en/daotekno/icon.png'; + site = 'https://daotekno.com/'; + + // Browser-like headers (important for Cloudflare-fronted sites, which + // may serve a bot-check page to requests without a User-Agent). The UA + // is a mobile Chrome: the site serves chapter pages only to mobile UAs. + 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, + }; + + // 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); + if (!res.ok) { + throw Object.assign(new Error('Request failed: ' + res.status), { + status: res.status, + }); + } + return res; + } + + private parseNovelCards(html: string): Plugin.NovelItem[] { + const $ = parseHTML(html); + const novels: Plugin.NovelItem[] = []; + $('a.novel-card.novel-item').each((_, el) => { + const href = $(el).attr('href'); + const name = + $(el).find('.novel-title').text().trim() || + $(el) + .text() + .replace(/Index\s*»/g, '') + .trim(); + if (!href || !name) return; + novels.push({ + name, + path: href.replace(/^\//, ''), + // The listing exposes no covers at all. + cover: defaultCover, + }); + }); + return novels; + } + + private collectChapters( + $: ReturnType, + ): Plugin.ChapterItem[] { + const chapters: Plugin.ChapterItem[] = []; + $('#allChaptersGrid > a.chapter-box').each((_, el) => { + const href = $(el).attr('href'); + const name = $(el).text().replace(/\s+/g, ' ').trim(); + if (!href || !name) return; + chapters.push({ name, path: href.replace(/^\//, '') }); + }); + return chapters; + } + + private nextChapterPage($: ReturnType): string | null { + const next = $('a.btn-page') + .filter((_, el) => $(el).text().includes('Next')) + .attr('href'); + return next ?? null; + } + + async popularNovels(pageNo: number): Promise { + 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 + // to stop pagination instead of returning the same cards forever. + const marker = parseHTML(html)('span.btn-pagi.active').text(); + const totalPages = parseInt(marker.split('/')[1] ?? '', 10); + if (Number.isFinite(totalPages) && pageNo > totalPages) { + return []; + } + + return this.parseNovelCards(html); + } + + async parseNovel(novelPath: string): Promise { + const firstHtml = await this.fetchSite(this.site + novelPath, { + headers: this.headers, + }).then(r => r.text()); + const $first = parseHTML(firstHtml); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $first('h1').first().text().trim() || 'Untitled', + // The novel page carries no cover, summary, author or status. + cover: defaultCover, + chapters: [], + }; + + const chapters = this.collectChapters($first); + let nextHref = this.nextChapterPage($first); + let pagesFetched = 1; + const maxChapterPages = 100; + + while (nextHref && pagesFetched < maxChapterPages) { + 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($); + pagesFetched++; + } + + // Every page lists the newest chapter first, and the pages themselves + // are served newest first, so the concatenation is fully descending. + novel.chapters = chapters.reverse(); + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const html = await this.fetchSite(this.site + chapterPath, { + headers: this.headers, + }).then(r => r.text()); + const $ = parseHTML(html); + + const content = $('#novel-content'); + if (content.length === 0) { + // The site serves a "Mobile Only Content" interstitial (HTTP 200) + // to desktop UAs instead of the chapter body; treat a page without + // the content container as a real failure, not empty content. + throw new Error('Chapter content not found (site served a placeholder)'); + } + + content.find('script, style, .promo').remove(); + return content.html() ?? ''; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + // Search is filtered server-side and served as a single page; the + // site ignores page parameters on `?q=` requests. + if (pageNo > 1) { + return []; + } + + const html = await this.fetchSite( + `${this.site}?q=${encodeURIComponent(searchTerm)}`, + { headers: this.headers }, + ).then(r => r.text()); + + return this.parseNovelCards(html); + } + + resolveUrl = (path: string) => this.site + path; +} + +export default new DaoTekno(); diff --git a/public/static/src/en/daotekno/icon.png b/public/static/src/en/daotekno/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0210fff960982d88a851f7dac6f24fde7d5fe70c GIT binary patch literal 7452 zcmbt(bx<2${B0-{D8(u6S}b@@+5TT0oZea5S%3WhcTlT;j|Mt${+|($c1~9ANHZsw|F;iB z^nVW$b>Yeb0@2>W9 zy&k`&6PGGJ6gkk~xZlbdCLY>O+n(L_k|3tW%HVv~dj0Kf*=tQ%nG5LngT&2LK-1Au z%WMfBDOi|H(kLO|`=jF$nHxzLf-a%gkMU(JqAz3#^E-*T&OhJ8-M)*vDEIyB?jfvnidnD{VgHIJa0zFD?& zm9blMxnfr3t!du6=ve z0mEemm1++2bW*J~Jkz9lbj;;KGHdC3;%7(2N&W88jBEn_=S$2V3Zd5qRR>F$c&f!J zLmn7Tm{zCXIYYz;sxXOWL21~pc=K53cgyw84snxYMEt`)E7yo<&x!z-$#VllUPsFc zpicc%af8H;PEWe$WIK&7ZK9zVHj)2>1Ale1CB{cZQH#m1KI)d{;(`6(xYoAQ(X7U^ z-J+`lYq6~(rDA!r?Sa33AUhv}^mD=^X110=Ykp=oEYdKhWSV9)+&$2kLLwsjP+Luu zFDAt>poD#k1%?afgVd}n8aT(~-EG|{S{UCU8 zJ%NSz!jJLBa@H%<0;@^EzvMZtn_Efj8bv(1OPyOjwL!g` zUCbnk)ifz`2c^*+t=b#ya^}iqi(SE2YirF435iE!C^O?c#+hx!r}(*I8ar(lV~gsT z`7$g&U<%VTHE$O;es$Boa8Nfu5)~WD@k(|RbKw?b88{PLvG>DM7mlF}XO+iDmsGtx zRcsA{u*>70TCbvm^B(gy-xya)|C#C=pDUKkGbi|VhO5d+fUgyDXZj^%0nC7`IxYSX zwOXyp*LPuT*j^Q@^$pwSwRd;HH7nmVW9`}jc{l1u{j-XfmL(#gLg~IuonxL+19xRU z?ti(w@1a5^9m#%|vX4Y!EvU)3H$oe{JH0-pN1Q1dJI$~9SfzVyQ_ZA&4MTidMoT7g z2|@9aY{f%(6QYk1*~vYsUdxZnbgwl|MC+an;OZa0pS53Hw%&az_2wGKVckhtLF80U z(>}jh)}}^jVaLM$$z5^ROD;02056-P;ARE;3@bEJuavMznb#t3*`1Q(NMe*TDSL>v5WoJiWEL~| z(g zEt!?OZ@FQlMh%Da9g{t&u1jfIuxigmjQ#9(8q+g*{X!8zih4V1nUQczh7xdB5{fY1 z*)mjsXTPq!se5|Uwzj7ws0gG{Wx-{bB8SKIL>Pz95_|#{T+&yUiP=Emd^)$X>4W~S zuclQo;Wsjm!?kqJdkB|{8Y5b;3w$rZ_@8O}Nt|9DK>(T+kx8v#Xr?wo1;0{k{DL0FsvL9eRSqKTuz(*>>p6!kZv@o z(A7cg??zGqN!QBdT3z3vzF(|Ad4B7Ajhr800*OgH4JAycQ%%|m248Dr!gu0S=;c(3 zQ&@GsS2a&I4NUh-@#Y2DM7jFP0L4$J_dd&-0K{)N;h7d*<2|Ho1^H`GYC9f>n{R-}d(1oR;|@4l;9en5MKOqmzQb{COog zk{>aD%~#jwI&`V{7@|(`&~^4x6bQYhUA3^-c>4i?l=LO*1*+=Qt4&SXaqN}-oA3Ha z!s;lvSt*%vKNe*+`S^zU9f^VSky~9ClQUDtz*R@0KbKK_ubEBV*vrZzxOC_ zv9;Bo;GOp;X(C%A@vEAx1Fd=-fRmaE#}&??A@S@79`9TP z46Nz&I@~o2m*4dqoTT@SUoD)N81TP~-{81t5pDALOKrk<7Yx9Uqa-4yfHy&mEwyjG z|5H-?Lh2LuNRT4Y57>*RHV*GmC?k`T)^)#iw>v4PO^}w#9Am}FRTtysKo?`vQhQfc z$1s~~nETVP*IoYGwYqZ~C?3D%)$?TFdA{_oBYQX?ttgwzAcHXYPKogvSYtx`lT4h^ z-&~HKrni>u!v^44`lL2V0A2zfFL8AR>*S$bD~WK6B&_K@De=|Qa-FxDwKoZ)Bu@(n zzaKCFa8H9UDbvTz^NwV@MO){#=P6Hae4@4KeF0HrqFlfPIA!%$_ZwhX1lTM?+f!0%JaaXDetX_>z{G-158cHX zHyB4U`96BKTyov()S-dm;13l>st8pdxufI?5Hho!qdLD-Ous~t8Tk4K)d_LA7&|Iz zYxV#%jHURuSR=|fD}hEksq&Kp zqnO!_z!06Lo#{4-YEh5j6+7+pX1QnU%j+Nkk6HSv<8r^S{bd|FwWQFRNyO#Uk6KFv zUh6}K)w}F)2urfq%{{jX-ECG#JJ^`@kIxy@X%lzmuV9_y`P~JFso|lLd!UGD^;yhm zA|=2DmVLi-9HRzFdQ)K>5H-EhpcdL@ZR%BlQlap&-sTc*brr5{GVY%-HPG%Knr9U( z+hnhRfc=xl;ciL8F_LEVOWY(+!M4ubV{FBESoa8X@-JQu?N}5_YEK>Q*`u!LW9Ax_af6t319LL30#dA+>>2U3^>B4%T_ z>B0lPzU4uF*S%_}8SOwYSb^-yQ94XyJ9zZf6c_>SgXo0r&`2fuo?LM)mOaa{mjS4S zgwkGJzsk|VUQl*t0!4$rO0#_tY?#Zbno?`&bX{3#RT^GWU@r*hv`%J;*@u73-S^7^ zkP00eNn^`V%>ADgGPU8 zjp;QQfIAA;=|)S-0ZhCnEHT8a2^ZwCKvdb{ChE_5g;pr`Huo_(yU;ZNTEhXewA?k* z=+c=hoqV0iS$jMsB)daSz4rlPhzrj!KyrWql+9k~0Z!C~AiEUuI;=1TP%vYAAR}@u zgXkVd>|Hqsn~1ibw?e+YemC>l^~U;)8n97mw2>>^flXs<9!kQi-lPQUO-OLhPgX}( zUW%`k6%iSwkbkom{cWPzcmrq?z&11ZE|DN36!ELEpRdeX{+58jbom@7-T8e~hT#fo z8FR>h7a?7wc})r#bD>)7SVf#H${$G`drM#$simeD6=2n#RklVz7~e*}`1s>@H{=sw zGJqVjQdxf#tTZ&iX=DLt@*BUXYUjR?et&5h_hxc%JBe6LIJV^FZ}+02pM>;RgV{1DT!*`AuCxq#+{ zs}scmB$F`N9;JVB?$T@7kP7hAt<#};8$$(U;vhaVn~ry9=|$Sp=^~$5PZ>B-EN%dL zl}Ue@Nxxrx*iv(VH@Do5{S-M{9#-<+s@8nL2rOq-18|H9f74`%~7lbn>;e&(|fI$%D9vCFc;_&n!Eo-LEegBBK1J^qg2uMJXO{ zHNTs)lC+Rr$j;|^R}5|)%WCeU3(v#ICpOTly=oZUc;DKR;pzfVV6SZXMyHoO-#?s{=)ae#ELp)6l{ zZY6ReT4~qz=5jcNiY3o6s`yu6?mc796L=iYa#TE?t2IxE&R<(~GTSZcENG#@PPL)$ zXs)*m95px*CLFOk#PT8fo!iZp)-5^{%5Ow8GeFis@sn`1a8$o@kxTBy33j3L<{(JbM1#_w{x#5E;vsFOcToq0 z6*JpMvT!1uu8EWwRw{Xd^FSQ{Pk_OJTep);{+9?+<&4jXc&55mI&YYOXUy99Lz9iz z=I0ZPW>}WP5|k@#5TFRYpN#I!uGv7@QwotMqmDrIeCzy);|f3Z0!5`%xwL7Q zR6lw$bxEl1**IP8);>^V)^2875YK4G!@~^-nJ_eZj|v$qcK-pIaz}l(SgOd85H1wa zGep|D3b<&pj?mKoGK1P>JL`t^1VezbK6-mK)xusk+qxvs0t?prwGiVr9}&}(55sWs zGSr^n8~7;)`2FLM#EzrZ^@pC^R%!9f)%=m#d-klq{O%G^u46+l`GtcCPWn}cQoXkS&EVtM^L_rN#p__$roWjn>##C63%6m}+X;UfksTyQ zge^-RCFIGJrpBLm*c0|{2qwh>CXj2FK%5SN6SwsD8!6{cLy7%?=6~}Q^U!jX)KHp2hN7s|M0a?Zf0z5~$ICR8sT#TJNC@$i~Z-Q(A_LsufCR_Tn);U`M7Fn+n#1Hh`Lf?qH%N<&&c4O0qGq%Rn2o+SV&TAD#9&SK!_zXC{z__7d%*^hnw9<13Ch@S zy1sp5o~n?@@Ki>X2u`EC?yCK2U>OXp(t+|6u>(X;g4TEi{(x7dxcNl$A`=tdk7Om4 z<$&al@C8Rn2$huJkL$8BwhYP!*hCuebO1ILD6$k>>jjtv$=y3^8ovV$AHbVdqqp*9 z*!Sp;e!#N(EL;(cVVm#c|5-O!Zj?Um0mbPaE+Q0s>+sO*^4*2S7=b z;%N79zWX#>LiK1afK7=)M}ntD&^7ZnecQg8x|zA%yovba1lkoCmCNI5_jN9>xxeT~|ij4DU6>|zr%>dEk4it5A`xya>CHXfKLYPC zd7wO?Te6haXV0?RBeLzZGomecG0nsYuRVTUsb>EP1_WJ}0SY z;vc2L_BHN*5IS$T+}P4`{vfqHZ5znRlj(*q=h)hATi*su-|-eq^;#p4P1wdM5Urdafy}kiDBxEBeK=qd5aX} z?!CFSCL$E(oIamclXT!rZI7I}MA z)Q&q1U=5FKGI?XxZ1bB-Hr|ryYuBa~Dz*hT`@VCUCbBP`9 zCb3GyP=Q)Z@i*}S{Av-MZzFDj0=38SLOte8ABd-^*>!Gvy~~awM2VFG&{p1;7zUL= z_O#5ILo5J}-lASmDbyc|G`N>cpmyhX7ykgIao%FRfgrEa8!xl?)oPfiqaa*D=LO~S zUzI}{fagT~3bG3WT%ZE{3~zWNG>&A9rG{5D)ZP02q|;RZ^Jvz^Q*4!QW~zgH)NpdYzxH(=nS>E z>cdxkn^S@(b|~FUM+k%8^~XnxUSHyujg5$o%LSb$_%h$`)fhh?0tcY`)VYI7yCzDeyOW!1FQ=koupiUIqO zR?pN3o~&T(Wgh+eKPFmvk(B95nLt81wR6MnA(S#^`XK@fO*oe!Q9$Lvnbr zD1Br36D{>ncbGM}m;I5{h9>fMD*XzhO%$VJgADaBf9Lz3;;C6bk=2-`Os@sW0*6C9Gtp`)A-zVNR}IKitHmC?c zB-CMbWWq{yj-nG&+IU=82{Lt`DPEgpaXma!N*HCt3?V&!*7fPnH{kZx+;M}$>kpU{ YY0+hm1)5L0>FDG{{R30 literal 0 HcmV?d00001 From 4f27b5a9c3dd085918ba635a453925c5ce158d75 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 22 Sep 2026 14:00:02 +0800 Subject: [PATCH 2/2] fix(english/daotekno): mirror Chrome client-hint and Sec-Fetch headers Add the sec-ch-ua trio and Sec-Fetch-Site/Dest fields a Chrome fetch() sends to this site, closing the gap between the plugin's request and a real browser request per docs/testing.md. Local probes with the hardened header set still receive Cloudflare's managed challenge (403, cf-mitigated: challenge), so this is header parity only. Co-Authored-By: AI agent pi --- plugins/english/daotekno.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/english/daotekno.ts b/plugins/english/daotekno.ts index c5882924f..a3b5b7a35 100644 --- a/plugins/english/daotekno.ts +++ b/plugins/english/daotekno.ts @@ -27,19 +27,27 @@ import { defaultCover } from '@libs/defaultCover'; class DaoTekno implements Plugin.PluginBase { id = 'daotekno'; name = 'DaoTekno'; - version = '1.0.0'; + version = '1.0.1'; icon = 'src/en/daotekno/icon.png'; site = 'https://daotekno.com/'; // Browser-like headers (important for Cloudflare-fronted sites, which - // may serve a bot-check page to requests without a User-Agent). The UA - // is a mobile Chrome: the site serves chapter pages only to mobile UAs. + // 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