From 60d12bad8cf67e68abeb2e3937c0617079d8c909 Mon Sep 17 00:00:00 2001 From: tmfahad39 Date: Sat, 24 Oct 2026 03:21:15 +0600 Subject: [PATCH 1/3] fix(english/lncrawler): keep chapter heading and add search filters - Keep the API's leading

in chapter bodies so readers see the chapter title (including its number) in the chapter header. - Add filter support to popularNovels/searchNovels via the search API: languages, status, sort_by, sort_order and adult content, with popular = popularity desc and Latest = last_updated desc. - Bump version to 1.1.0. Co-Authored-By: opencode --- plugins/english/lncrawler.ts | 160 ++++++++++++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 11 deletions(-) diff --git a/plugins/english/lncrawler.ts b/plugins/english/lncrawler.ts index b40a2012f..0ee127315 100644 --- a/plugins/english/lncrawler.ts +++ b/plugins/english/lncrawler.ts @@ -1,6 +1,7 @@ import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; // LNCrawler — https://lncrawler.monster // The web frontend is a React SPA; all data comes from its public JSON @@ -304,32 +305,168 @@ function parseChaptersPage(data: unknown): ChaptersPage { /** * Chapter body HTML. The API prefixes the body with an

of the - * chapter title — the app already shows the title, so it is dropped. + * chapter title — which includes the chapter number on LNCrawler. + * Keeping it gives in-page headings showing title and number, so it + * is left in place (only executable content is stripped below). */ function parseChapterBody(data: unknown): string { const res = data as { body?: string }; if (typeof res.body !== 'string') { throw new Error('Chapter response had an unexpected shape'); } - const withoutTitle = res.body - .replace(/^\s*]*>[\s\S]*?<\/h1>\s*/i, '') - .trim(); - return sanitizeChapterHtml(withoutTitle); + return sanitizeChapterHtml(res.body.trim()); } +/** + * Languages the LNCrawler search API understands (from the site's own + * frontend), used for the Languages filter. Responses repeat the + * `languages` param, which the API aliases to `language`. + */ +const LANGUAGES: { label: string; value: string }[] = [ + { label: 'English', value: 'en' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' }, + { label: 'German', value: 'de' }, + { label: 'Italian', value: 'it' }, + { label: 'Japanese', value: 'ja' }, + { label: 'Korean', value: 'ko' }, + { label: 'Chinese', value: 'zh' }, + { label: 'Portuguese', value: 'pt' }, + { label: 'Russian', value: 'ru' }, + { label: 'Arabic', value: 'ar' }, + { label: 'Hindi', value: 'hi' }, + { label: 'Thai', value: 'th' }, + { label: 'Vietnamese', value: 'vi' }, + { label: 'Indonesian', value: 'id' }, + { label: 'Turkish', value: 'tr' }, + { label: 'Polish', value: 'pl' }, + { label: 'Dutch', value: 'nl' }, + { label: 'Swedish', value: 'sv' }, + { label: 'Danish', value: 'da' }, +]; + +const SORT_OPTIONS: { label: string; value: string }[] = [ + { label: 'Title', value: 'title' }, + { label: 'Rating', value: 'rating' }, + { label: 'Date added', value: 'date_added' }, + { label: 'All-time views', value: 'popularity' }, + { label: 'Trending', value: 'trending' }, + { label: 'Last updated', value: 'last_updated' }, +]; + +const STATUS_OPTIONS: { label: string; value: string }[] = [ + { label: 'Ongoing', value: 'Ongoing' }, + { label: 'Completed', value: 'Completed' }, + { label: 'On Hiatus', value: 'On Hiatus' }, + { label: 'Cancelled', value: 'Cancelled' }, + { label: 'Unknown', value: 'Unknown' }, +]; + +const ADULT_OPTIONS: { label: string; value: string }[] = [ + { label: 'Hide adult content', value: 'hide' }, + { label: 'Show everything', value: 'show' }, + { label: 'Adult content only', value: 'only' }, +]; + class LNCrawler implements Plugin.PluginBase { id = 'lncrawler'; name = 'LNCrawler'; icon = 'src/en/lncrawler/icon.png'; site = 'https://lncrawler.monster'; - version = '1.0.1'; + version = '1.1.0'; + + filters = { + languages: { + type: FilterTypes.CheckboxGroup, + label: 'Languages', + value: ['en'], + options: LANGUAGES, + }, + status: { + type: FilterTypes.Picker, + label: 'Status', + value: '', + options: STATUS_OPTIONS, + }, + sort_by: { + type: FilterTypes.Picker, + label: 'Sort by', + value: '', + options: SORT_OPTIONS, + }, + sort_order: { + type: FilterTypes.Picker, + label: 'Order', + value: '', + options: [ + { label: 'Ascending', value: 'asc' }, + { label: 'Descending', value: 'desc' }, + ], + }, + adult: { + type: FilterTypes.Picker, + label: 'Adult content', + value: '', + options: ADULT_OPTIONS, + }, + } satisfies Filters; + + /** + * Build the /novels/search/ query from filters. Catalog defaults: + * popular = all-time views descending; the Latest toggle switches to + * last-updated descending. An empty filter value means "server + * default" and is simply omitted. + */ + private buildSearchQuery( + options: Plugin.PopularNovelsOptions, + pageNo: number, + query?: string, + ): URLSearchParams { + const params = new URLSearchParams({ + page: String(pageNo), + page_size: String(PAGE_SIZE), + }); + if (query) params.set('query', query); + const opts: Plugin.PopularNovelsOptions = options || { + showLatestNovels: false, + filters: undefined, + }; + const filters = opts.filters; + if (!filters && !options) { + // Interface callers pass no options: catalog default = English. + params.append('languages', 'en'); + } else { + for (const language of filters?.languages?.value || []) { + if (language) params.append('languages', language); + } + } + if (filters?.status?.value) params.set('status', filters.status.value); + if (filters?.sort_by?.value) { + params.set('sort_by', filters.sort_by.value); + } else { + params.set( + 'sort_by', + opts.showLatestNovels ? 'last_updated' : 'popularity', + ); + } + if (filters?.sort_order?.value) { + params.set('sort_order', filters.sort_order.value); + } else { + params.set('sort_order', 'desc'); + } + if (filters?.adult?.value) params.set('adult', filters.adult.value); + return params; + } - async popularNovels(pageNo: number): Promise { + async popularNovels( + pageNo: number, + options: Plugin.PopularNovelsOptions, + ): Promise { // The search endpoint is the only listing with a language filter; - // with no query it serves as the catalog. The API exposes no sort - // options, so the Latest toggle lands here too. + // with no query it serves as the catalog. Popular = all-time views + // descending, the Latest toggle = last-updated descending. const data = await fetchJson( - `${API}/novels/search/?language=en&page=${pageNo}&page_size=${PAGE_SIZE}`, + `${API}/novels/search/?${this.buildSearchQuery(options, pageNo)}`, 'Failed to load popular novels', ); const page = parseNovelListPage(data); @@ -417,9 +554,10 @@ class LNCrawler implements Plugin.PluginBase { async searchNovels( searchTerm: string, pageNo: number, + options?: Plugin.PopularNovelsOptions, ): Promise { const data = await fetchJson( - `${API}/novels/search/?query=${encodeURIComponent(searchTerm)}&language=en&page=${pageNo}&page_size=${PAGE_SIZE}`, + `${API}/novels/search/?${this.buildSearchQuery(options, pageNo, searchTerm)}`, 'Failed to search novels', ); const page = parseNovelListPage(data); From a045b068245800be2b7c960867c3c07e42dccc71 Mon Sep 17 00:00:00 2001 From: tmfahad39 Date: Sat, 24 Oct 2026 03:42:30 +0600 Subject: [PATCH 2/3] =?UTF-8?q?fix(english/lncrawler):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20language-aware=20sources,=20clearable=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Catalog cards carry the chosen languages in their path (slug?lang=a,b); parseNovel now prefers a chapter source in one of those languages over the default English preference. - Add All/Default (empty) options to status, sort, order and adult pickers so each filter can be cleared individually. - Make novel slug extraction robust for bare-slug and resolved URLs. - Bump version to 1.1.1. Co-Authored-By: opencode --- plugins/english/lncrawler.ts | 72 +++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/plugins/english/lncrawler.ts b/plugins/english/lncrawler.ts index 0ee127315..a341dec76 100644 --- a/plugins/english/lncrawler.ts +++ b/plugins/english/lncrawler.ts @@ -207,9 +207,36 @@ function parseNovelSources(data: unknown): NovelSources { * is English). Falls back to the largest source overall when the novel * has no English source, so the novel still opens. */ -function chooseSource(detail: NovelSources): SourceOption { +/** + * Pick the source the plugin reads through. When the catalog was + * filtered to languages, those are carried in the novel path + * (`slug?lang=a,b`) and a source in one of them wins — first listed + * language first. Without them this is: the English source with the + * most available chapters (the site's preferred source wins ties when + * it is English), falling back to the largest source overall when the + * novel has no English source, so the novel still opens. + */ +function chooseSource( + detail: NovelSources, + preferLangs: string[] = [], +): SourceOption { let bestEn: SourceOption | undefined; let bestAny: SourceOption | undefined; + for (const lang of preferLangs) { + let bestInLang: SourceOption | undefined; + for (const s of detail.sources) { + if (s.available <= 0 || s.language !== lang) continue; + if ( + !bestInLang || + s.available > bestInLang.available || + (s.available === bestInLang.available && + s.slug === detail.preferredSlug) + ) { + bestInLang = s; + } + } + if (bestInLang) return bestInLang; + } for (const s of detail.sources) { if (s.available <= 0) continue; if (!bestAny || s.available > bestAny.available) bestAny = s; @@ -346,6 +373,7 @@ const LANGUAGES: { label: string; value: string }[] = [ ]; const SORT_OPTIONS: { label: string; value: string }[] = [ + { label: 'Default', value: '' }, { label: 'Title', value: 'title' }, { label: 'Rating', value: 'rating' }, { label: 'Date added', value: 'date_added' }, @@ -355,6 +383,7 @@ const SORT_OPTIONS: { label: string; value: string }[] = [ ]; const STATUS_OPTIONS: { label: string; value: string }[] = [ + { label: 'All', value: '' }, { label: 'Ongoing', value: 'Ongoing' }, { label: 'Completed', value: 'Completed' }, { label: 'On Hiatus', value: 'On Hiatus' }, @@ -363,6 +392,7 @@ const STATUS_OPTIONS: { label: string; value: string }[] = [ ]; const ADULT_OPTIONS: { label: string; value: string }[] = [ + { label: 'Default', value: '' }, { label: 'Hide adult content', value: 'hide' }, { label: 'Show everything', value: 'show' }, { label: 'Adult content only', value: 'only' }, @@ -399,6 +429,7 @@ class LNCrawler implements Plugin.PluginBase { label: 'Order', value: '', options: [ + { label: 'Default', value: '' }, { label: 'Ascending', value: 'asc' }, { label: 'Descending', value: 'desc' }, ], @@ -470,11 +501,23 @@ class LNCrawler implements Plugin.PluginBase { 'Failed to load popular novels', ); const page = parseNovelListPage(data); - return pageNo > page.totalPages ? [] : page.cards; + return this.tagWithLanguages(page, pageNo, options); + } + + /** Selected languages carried in the novel path, if any (`slug?lang=a,b`). */ + private langsFromPath(novelPath: string): string[] { + const url = new URL(novelPath, this.site); + const raw = url.searchParams.get('lang') || ''; + return raw.split(',').filter(Boolean); } async parseNovel(novelPath: string): Promise { - const slug = novelPath.replace(/^\/novels\//, '').replace(/\/+$/, ''); + // The path may be a bare slug (`shadow-slave?lang=en`) or a resolved + // site URL (`.../novels/shadow-slave?lang=en`); the last path + // segment is the novel slug either way. + const slug = + new URL(novelPath, this.site).pathname.split('/').filter(Boolean).pop() || + ''; const detail = parseNovelSources( await fetchJson( `${API}/novels/${encodeURIComponent(slug)}/`, @@ -484,7 +527,7 @@ class LNCrawler implements Plugin.PluginBase { if (!detail.name) { throw new Error('Could not load the novel ' + novelPath); } - const source = chooseSource(detail); + const source = chooseSource(detail, this.langsFromPath(novelPath)); const base = `${API}/novels/${encodeURIComponent(slug)}` + `/${encodeURIComponent(source.slug)}`; @@ -561,7 +604,26 @@ class LNCrawler implements Plugin.PluginBase { 'Failed to search novels', ); const page = parseNovelListPage(data); - return pageNo > page.totalPages ? [] : page.cards; + return this.tagWithLanguages(page, pageNo, options); + } + + /** + * Catalog cards carry the chosen languages in their path + * (`slug?lang=a,b`) so parseNovel can pick a chapter source that + * matches them; used only when a language filter is actually set. + */ + private tagWithLanguages( + page: ListPage, + pageNo: number, + options?: Plugin.PopularNovelsOptions, + ): Plugin.NovelItem[] { + if (pageNo > page.totalPages) return []; + const langs = (options?.filters?.languages?.value || []) + .filter(Boolean) + .join(','); + if (!langs) return page.cards; + const suffix = `?lang=${encodeURIComponent(langs)}`; + return page.cards.map(card => ({ ...card, path: card.path + suffix })); } resolveUrl = (path: string) => { From b92a5d308669b8465e3607e1af5e2b38aa96f239 Mon Sep 17 00:00:00 2001 From: tmfahad39 Date: Sat, 24 Oct 2026 03:42:53 +0600 Subject: [PATCH 3/3] chore(english/lncrawler): bump version to 1.1.1 Co-Authored-By: opencode --- plugins/english/lncrawler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/lncrawler.ts b/plugins/english/lncrawler.ts index a341dec76..76efc26ee 100644 --- a/plugins/english/lncrawler.ts +++ b/plugins/english/lncrawler.ts @@ -403,7 +403,7 @@ class LNCrawler implements Plugin.PluginBase { name = 'LNCrawler'; icon = 'src/en/lncrawler/icon.png'; site = 'https://lncrawler.monster'; - version = '1.1.0'; + version = '1.1.1'; filters = { languages: {