diff --git a/plugins/english/lncrawler.ts b/plugins/english/lncrawler.ts index b40a2012f..76efc26ee 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 @@ -206,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; @@ -304,40 +332,192 @@ 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: 'Default', value: '' }, + { 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: 'All', value: '' }, + { 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: 'Default', value: '' }, + { 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.1'; + + 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: 'Default', value: '' }, + { 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); - 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)}/`, @@ -347,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)}`; @@ -417,13 +597,33 @@ 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); - 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) => {