From ae62a558a23a789c5988a4638f320903e2a97cea Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Wed, 30 Sep 2026 17:33:55 +0800 Subject: [PATCH 1/3] feat(english/asianfanfics): add Asianfanfics plugin Closes #2383 --- plugins/english/asianfanfics.ts | 246 +++++++++++++++++++++ public/static/src/en/asianfanfics/icon.png | Bin 0 -> 2308 bytes 2 files changed, 246 insertions(+) create mode 100644 plugins/english/asianfanfics.ts create mode 100644 public/static/src/en/asianfanfics/icon.png diff --git a/plugins/english/asianfanfics.ts b/plugins/english/asianfanfics.ts new file mode 100644 index 000000000..2da1b846e --- /dev/null +++ b/plugins/english/asianfanfics.ts @@ -0,0 +1,246 @@ +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +class AsianFanfics implements Plugin.PluginBase { + id = 'asianfanfics'; + name = 'Asianfanfics'; + version = '1.0.0'; + icon = 'src/en/asianfanfics/icon.png'; + site = 'https://www.asianfanfics.com/'; + + // Listings, story details and chapter text are HTMX fragments that the + // pages load after render; fetch those fragments directly. + async fetchHtml(url: string, referer?: string): Promise { + const res = await fetchApi(url, { + headers: referer ? { Referer: referer } : undefined, + }); + if (!res.ok) { + throw new Error(`Could not reach ${url} (${res.status})`); + } + return parseHTML(await res.text()); + } + + parseNovels($: CheerioAPI): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + + $('article').each((_, ele) => { + const link = $(ele).find('h3 a[href*="/story/view/"]').first(); + const href = link.attr('href'); + if (!href) return; + + novels.push({ + name: link.text().trim(), + path: href.replace(/^\//, ''), + cover: $(ele).find('img').first().attr('src') || defaultCover, + }); + }); + + return novels; + } + + async popularNovels( + page: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const sort = showLatestNovels ? 'latest' : filters.sort.value; + const params = new URLSearchParams({ page: page.toString() }); + if (filters.language.value) { + params.set('language', filters.language.value); + } + + const $ = await this.fetchHtml( + `${this.site}htmx/browse/en/${sort}?${params.toString()}`, + ); + return this.parseNovels($); + } + + async parseNovel(novelPath: string): Promise { + const url = this.site + novelPath; + const $ = await this.fetchHtml(url); + const header = $('main header').first(); + + header.find('h1 span').remove(); + const novel: Plugin.SourceNovel = { + path: novelPath, + name: header.find('h1').text().trim() || 'Untitled', + cover: header.find('img').first().attr('src') || defaultCover, + author: header.find('a[href^="/profile/"]').first().text().trim(), + genres: header + .find('a[href^="/browse/tag/"]') + .map((_, ele) => $(ele).text().trim()) + .get() + .join(', '), + status: header + .find('span') + .toArray() + .some(ele => $(ele).text().trim().toLowerCase() === 'completed') + ? NovelStatus.Completed + : NovelStatus.Ongoing, + }; + + const descriptionPath = $('#bodyText [hx-get^="/htmx/story/"]').attr( + 'hx-get', + ); + if (descriptionPath) { + const description = await this.fetchHtml( + this.site + descriptionPath.replace(/^\//, ''), + url, + ); + novel.summary = description('#story-description').text().trim(); + } + + // The table of contents is rendered twice (sidebar and mobile sheet), and + // its entry 0 is the foreword, i.e. the story page itself. + const chapters: Plugin.ChapterItem[] = []; + const seen = new Set(); + $('a[data-toc-chapter]:not([data-toc-chapter="0"])').each((_, ele) => { + const href = $(ele).attr('href'); + if (!href || seen.has(href)) return; + seen.add(href); + + chapters.push({ + name: $(ele).find('span.truncate').text().trim(), + path: href.replace(/^\//, ''), + chapterNumber: + parseInt($(ele).find('span').first().text().trim(), 10) || + chapters.length + 1, + }); + }); + novel.chapters = chapters; + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const url = this.site + chapterPath; + const $ = await this.fetchHtml(url); + const body = $('#bodyText'); + + const contentPath = body + .find('[hx-get^="/htmx/chapter/"]') + .first() + .attr('hx-get'); + if (contentPath) { + const chapter = await this.fetchHtml( + this.site + contentPath.replace(/^\//, ''), + url, + ); + return this.cleanHtml(chapter); + } + + // Members-only, subscribers-only and friends-only chapters show a notice + // and, sometimes, a public teaser instead of the chapter text. + const notice = + body.find('div.border-dashed:not(#content-load-error)').first().text() || + 'This chapter is not publicly available.'; + const message = parseHTML('

'); + message('strong').text( + `${notice.trim()} This chapter requires an Asianfanfics account and cannot be read in this plugin.`, + ); + let html = message('body').html() || ''; + + const teaserPath = body + .find('[hx-get^="/htmx/teaser/"]') + .first() + .attr('hx-get'); + if (teaserPath) { + const teaser = await this.fetchHtml( + this.site + teaserPath.replace(/^\//, ''), + url, + ); + const teaserHtml = this.cleanHtml(teaser); + if (teaserHtml.trim()) { + html += `

Teaser:

${teaserHtml}`; + } + } + return html; + } + + // Returns the fragment's chapter body without scripts, event handlers or + // javascript: URLs. + cleanHtml($: CheerioAPI): string { + const content = $('#user-submitted-body'); + content.find('script, style, iframe, object, embed, form').remove(); + content.find('*').each((_, ele) => { + if (ele.type !== 'tag') return; + for (const name of Object.keys(ele.attribs)) { + const value = ele.attribs[name].trim().toLowerCase(); + if ( + name.toLowerCase().startsWith('on') || + value.startsWith('javascript:') + ) { + $(ele).removeAttr(name); + } + } + }); + return content.html() || ''; + } + + async searchNovels( + searchTerm: string, + page: number, + ): Promise { + const params = new URLSearchParams({ + q: searchTerm, + page: page.toString(), + }); + const $ = await this.fetchHtml( + `${this.site}htmx/browse/search?${params.toString()}`, + `${this.site}browse/search?${params.toString()}`, + ); + return this.parseNovels($); + } + + resolveUrl = (path: string) => this.site + path; + + filters = { + sort: { + label: 'Sort', + value: 'trending', + options: [ + { label: 'Trending', value: 'trending' }, + { label: 'Latest', value: 'latest' }, + { label: 'Newest', value: 'newest' }, + { label: 'Completed', value: 'completed' }, + { label: 'One Shots', value: 'one-shots' }, + { label: 'Featured', value: 'featured' }, + { label: 'Views', value: 'views' }, + { label: 'Subscriptions', value: 'favorited' }, + { label: 'Commented', value: 'commented' }, + ], + type: FilterTypes.Picker, + }, + language: { + label: 'Language', + value: '', + options: [ + { label: 'All languages', value: '' }, + { label: 'Arabic', value: 'ar' }, + { label: 'Chinese', value: 'zh' }, + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'German', value: 'de' }, + { label: 'Indonesian', value: 'id' }, + { label: 'Japanese', value: 'ja' }, + { label: 'Korean', value: 'ko' }, + { label: 'Malay', value: 'ms' }, + { label: 'Portuguese', value: 'pt' }, + { label: 'Russian', value: 'ru' }, + { label: 'Spanish', value: 'es' }, + { label: 'Tagalog / Filipino', value: 'tl' }, + { label: 'Thai', value: 'th' }, + { label: 'Vietnamese', value: 'vi' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +export default new AsianFanfics(); diff --git a/public/static/src/en/asianfanfics/icon.png b/public/static/src/en/asianfanfics/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..301f0946f2a1caa9af513d97eb9e9ec0e32f53d3 GIT binary patch literal 2308 zcmV+f3H$bmP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw000P& zNklJyHJa4cRILcK)S@OXV1g0E0%_d^c6k(*$G&%AcG;bodq4l)VM9Y`cX#fc zduL|vH~D3f&ECwN?>pz*`#`@IGN)&?r6E za^X%$3V#b)qi<}}09FN!(KoHt04f3>eA99rc;KsyAON4`NdTnB1OePs*ifj5X-_E% zDOdzdD-AhL128RM)to4{KR5~7*L%ay<+Hwje-*xbUj-hnE5)66MC?#3!Y1dG&HLPy zKWycem%8bfEQ=gSSZunI;7jM)}H#-W52!Ew9&ZHHvU`iN|udBj?D@tJ~fI$G>t1_msacw!) z+aUx2_@Y=?VdI)|y#K8wuowj3i;5(KkJpu9`K$;m1_Ah@sbz*e9F-SokU;>xXz7e7 z-n+Os=mCCCOTMc%hVqyOg8=-|!l_}Ix`@KEf&hF`NGse^Q-o+pjqd;~0x6S7S)*h4 zf8EfESy4fJrC%&R@Fa+%JsJ$+*EJZ9-bX&=?aYftREgJahP( zqZT}?G6X}Z@i_!t>`UR2kjeK~Nb4V`2hkokk*VfXg`hRYX8)JqIM$lLXq7Yxt;S}!Mc9gjfXbMG>Z*{u>mY#Psy1N~Bgl#2_Fc=OsbMUd z7D3IVFs7Cph#D#gz-z;mLf$Dvlt(pYRE98LOkXl1g2iIGw0J~3Zs7z07H|(B(-cRPebKC$9Kl<}@LFl<(&;RHw&UJtNH6o)n5y|@?;ttdtH#4xl% z&WWD(0ML_fWD@oT^i!+LQ9nOskLkHAlJG(y(wpso_twPlxw|VcS6Y2Oh!B|PbpQn| zd|+8A9uf0PBEb^$t!KALd#Wt*^eUXkpxEXn#T zYtl>`e(2mFp88iGzOt(WpZQ%IKL1=BnmZD3B6{<3zY-2<;GMN4t`9vVFqBFgeyTlz z#w!W?{bFy*UVtA+5G0|MLfCNO7BU|zVT(ZRlnCBdUF6C|Jf5&{u_q;F5200ze)Vz! z=em;Uy>23rf{0K`K`YqYoXb#fD&}J)Os1a;o>XEu3h|XoLwNpp98GO$!U}9rLjgCV{dd0}G}` zkg1Nf4&up%ZgdY=FtiFP;jv%|)5;7?i0O=no}I_z_U)r`qY`!4nn>hVC2W!QQek<_ zKqgA0h}XIj$Q{wE9ssz5i**kjl1D$Wqv4vIg%AlT)J%%FnPeWw#Yz|=(;_S((=~$! zmX%>bi2;+2&&PRw-n`tK3sL}1cP1Qd3tuulf)7auQA4G7Nn2y@(v~IAc?h5vxLgTe zPZCL!4z@BXeD>}MsF7-V$IDl7wkrvXnN5d;4V53G*y3-u&@GmK?=<(H>Zcp(A2NBi zGr=<%AjjE{I%TTAHpaQ=u@)}e(!yGKU*y1(k#I8r`(%H6xR?2^)}ACE-`l}Q{&1Q1 zZ$Hnwww~p(@1AC@9ZIu=<6;1kF5O~*oZO>-yuzeuG2dlbq$zYj=vn!stx*($z(RMY7hEx40R4I2C zZQNgyG%aA~(W|)JmqNjc1WC7+Bmp;33TWv_VE6HBAW@JalxwAe&epcRXAbw`$ocCi zOcBiGjad-M-2=9~*pB0^gD5yr$n}j`S^+2964?BFTfv1u8xJ;SIYn#WMB5NHNeG9| z4#2cfIHF4)fkZYj6mUvH_`-9S@v{S6Xzfjc1#t~<#~)1q*+wa#yWhl5_I2TtPhZ6L z1KntDPuTl>@==oimF0+AL#@I?Z91q67)?+Nb2>osRnW1Y|}Fd(ZO z6lUUfn!Ty9UT6^*kQG{l`ZxR8%KZR84YElne9sPK@mOXw7=z|2vT5-ti)32TKgGL;-05$c7_!d~HskP1w`t?5T@c=T0a esW9RLbN>UiJcsTBzu#s60000 Date: Wed, 30 Sep 2026 17:36:51 +0800 Subject: [PATCH 2/3] fix(english/asianfanfics): keep HTTP status on failed requests Carry the response status on thrown fetch errors so a Cloudflare block is reported as a block instead of a plugin failure. --- plugins/english/asianfanfics.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/english/asianfanfics.ts b/plugins/english/asianfanfics.ts index 2da1b846e..02aba0c71 100644 --- a/plugins/english/asianfanfics.ts +++ b/plugins/english/asianfanfics.ts @@ -19,7 +19,12 @@ class AsianFanfics implements Plugin.PluginBase { headers: referer ? { Referer: referer } : undefined, }); if (!res.ok) { - throw new Error(`Could not reach ${url} (${res.status})`); + // Keep the status and response on the error so an anti-bot block is + // reported as such rather than as a parsing failure. + throw Object.assign(new Error(`Could not reach ${url} (${res.status})`), { + status: res.status, + response: res, + }); } return parseHTML(await res.text()); } From fbcc780bd4ba32f852547708cd8b943960468d2f Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Wed, 30 Sep 2026 17:39:51 +0800 Subject: [PATCH 3/3] fix(english/asianfanfics): strip inline styles from chapter HTML Chapter and teaser bodies are author-written; drop style attributes and link/meta tags so content cannot be positioned over the reader. --- plugins/english/asianfanfics.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/english/asianfanfics.ts b/plugins/english/asianfanfics.ts index 02aba0c71..066aa7e63 100644 --- a/plugins/english/asianfanfics.ts +++ b/plugins/english/asianfanfics.ts @@ -168,11 +168,15 @@ class AsianFanfics implements Plugin.PluginBase { return html; } - // Returns the fragment's chapter body without scripts, event handlers or - // javascript: URLs. + // Returns the fragment's chapter body without scripts, stylesheets, inline + // styles, event handlers or javascript: URLs. The body is author-written, so + // inline styles could otherwise lay content over the reader. cleanHtml($: CheerioAPI): string { const content = $('#user-submitted-body'); - content.find('script, style, iframe, object, embed, form').remove(); + content + .find('script, style, link, meta, iframe, object, embed, form') + .remove(); + content.find('[style]').removeAttr('style'); content.find('*').each((_, ele) => { if (ele.type !== 'tag') return; for (const name of Object.keys(ele.attribs)) {