From fd15c0899900e565e92d47727ee4e3d5adb80a18 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:59:23 +0800 Subject: [PATCH 1/3] feat(chinese/lewenn): add lewenn.com source plugin Browse the home page and category listings, open a book with its full chapter list, and read chapters. Pages are GBK and decoded accordingly. Search returns no results: lewenn.com has no search of its own, and its third-party search provider refuses requests from this region. --- plugins/chinese/lewenn.ts | 231 +++++++++++++++++++++++++++ public/static/src/cn/lewenn/icon.png | Bin 0 -> 3489 bytes 2 files changed, 231 insertions(+) create mode 100644 plugins/chinese/lewenn.ts create mode 100644 public/static/src/cn/lewenn/icon.png diff --git a/plugins/chinese/lewenn.ts b/plugins/chinese/lewenn.ts new file mode 100644 index 000000000..7dc1819af --- /dev/null +++ b/plugins/chinese/lewenn.ts @@ -0,0 +1,231 @@ +import { Plugin } from '@/types/plugin'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { fetchText } from '@libs/fetch'; +import { NovelStatus } from '@libs/novelStatus'; +import { load as parseHTML } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; + +const SITE = 'https://www.lewenn.com/'; + +// novel: lw11754/ ; chapter: lw11754/30307582.html +const NOVEL_PATH = /^lw\d+\/$/; +const CHAPTER_PATH = /^lw\d+\/\d+\.html$/; + +// promotional lines that the chapter body carries after the text +const AD_LINE = /乐文小说网|lewenn\.com|^\(https?:\/\//; + +const toPath = (href?: string): string | undefined => { + if (!href) return undefined; + try { + return new URL(href, SITE).pathname.replace(/^\//, ''); + } catch { + return undefined; + } +}; + +// the site stores cover images at files/article/image///s.jpg +const coverOf = (novelPath: string): string => { + const id = novelPath.match(/^lw(\d+)\//)?.[1]; + if (!id) return defaultCover; + return `${SITE}files/article/image/${Math.floor(Number(id) / 1000)}/${id}/${id}s.jpg`; +}; + +// returns the text after a full- or half-width colon, e.g. "作者:水果饺子" +const valueAfterColon = (text: string): string => + text + .replace(/\u00a0/g, ' ') + .replace(/^[^::]*[::]\s*/, '') + .trim(); + +class LeWennPlugin implements Plugin.PluginBase { + id = 'lewenn'; + name = '乐文小说网'; + site = SITE; + version = '1.0.0'; + icon = 'src/cn/lewenn/icon.png'; + defaultCover = defaultCover; + + filters = { + genre: { + label: '分类', + value: 'home', + options: [ + { label: '首页', value: 'home' }, + { label: '玄幻魔法', value: '1' }, + { label: '武侠修真', value: '2' }, + { label: '都市言情', value: '3' }, + { label: '历史军事', value: '4' }, + { label: '侦探推理', value: '5' }, + { label: '网游动漫', value: '6' }, + { label: '科幻小说', value: '7' }, + { label: '恐怖灵异', value: '8' }, + { label: '其他类型', value: '10' }, + { label: '全本小说', value: 'full' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; + + resolveUrl = (path: string) => this.site + path; + + // collects every book link on a page; the chapter links in the update + // rows point at /lwN/.html and are skipped by the NOVEL_PATH check + private parseNovelList($: ReturnType): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + + $('a[href]').each((_i, el) => { + const $a = $(el); + const name = $a + .text() + .replace(/\u00a0/g, ' ') + .trim(); + const path = toPath($a.attr('href')); + if (!name || !path || !NOVEL_PATH.test(path) || seen.has(path)) return; + seen.add(path); + novels.push({ name, path, cover: coverOf(path) }); + }); + + return novels; + } + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions, + ): Promise { + // the site serves each listing as a single page (/class/1_2.html is 404) + if (pageNo > 1) return []; + + const genre = filters.genre.value; + const url = + genre === 'home' + ? this.site + : genre === 'full' + ? `${this.site}full.html` + : `${this.site}class/${genre}_1.html`; + + const body = await fetchText(url, undefined, 'gbk'); + if (!body) throw new Error(`[LeWenn] Failed to fetch: ${url}`); + + return this.parseNovelList(parseHTML(body)); + } + + async parseNovel(novelPath: string): Promise { + if (!NOVEL_PATH.test(novelPath)) + throw new Error(`[LeWenn] Invalid novel path: ${novelPath}`); + + const url = this.resolveUrl(novelPath); + const body = await fetchText(url, undefined, 'gbk'); + if (!body) throw new Error(`[LeWenn] Failed to fetch: ${url}`); + + const $ = parseHTML(body); + const getMeta = (prop: string) => + $(`meta[property="${prop}"]`).attr('content')?.trim() || ''; + + const $info = $('#info'); + const infoLine = (label: string) => + $info + .find('p') + .filter((_i, p) => $(p).text().includes(label)) + .first() + .text(); + + const statusText = getMeta('og:novel:status') || infoLine('状态'); + + return { + path: novelPath, + name: getMeta('og:novel:book_name') || $info.find('h1').text().trim(), + cover: getMeta('og:image') || coverOf(novelPath), + summary: + $('#intro') + .text() + .replace(/\u00a0/g, ' ') + .trim() || undefined, + author: + getMeta('og:novel:author') || + valueAfterColon(infoLine('作者')) || + undefined, + genres: getMeta('og:novel:category') || undefined, + status: statusText.includes('完结') + ? NovelStatus.Completed + : statusText + ? NovelStatus.Ongoing + : NovelStatus.Unknown, + chapters: this.parseChapterList($), + }; + } + + // the page lists the newest 12 chapters under a 最新章节 heading, then the + // whole book under 正文卷 in reading order; only the 正文卷 block is kept + private parseChapterList( + $: ReturnType, + ): Plugin.ChapterItem[] { + const mainText = new Map(); + const everyLink = new Map(); + let inMainText = false; + + $('.listmain dl') + .children() + .each((_i, el) => { + const $el = $(el); + if ($el.is('dt')) { + inMainText = $el.text().includes('正文'); + return; + } + if (!$el.is('dd')) return; + + const path = toPath($el.find('a').attr('href')); + const name = $el.find('a').text().trim(); + if (!path || !name || !CHAPTER_PATH.test(path)) return; + + const chapter = { name, path }; + everyLink.set(path, chapter); + if (inMainText) mainText.set(path, chapter); + }); + + // fall back to every link when the page has no 正文卷 heading + const list: Plugin.ChapterItem[] = []; + (mainText.size ? mainText : everyLink).forEach(chapter => + list.push({ ...chapter, chapterNumber: list.length + 1 }), + ); + return list; + } + + async parseChapter(chapterPath: string): Promise { + if (!CHAPTER_PATH.test(chapterPath)) + throw new Error(`[LeWenn] Invalid chapter path: ${chapterPath}`); + + const url = this.resolveUrl(chapterPath); + const body = await fetchText(url, undefined, 'gbk'); + if (!body) throw new Error(`[LeWenn] Failed to fetch chapter: ${url}`); + + const $ = parseHTML(body); + const $content = $('#content'); + $content.find('script, ins, iframe').remove(); + + const paragraphs = ($content.html() || '') + .split(//i) + .map(part => + parseHTML(part) + .text() + .replace(/\u00a0/g, ' ') + .trim(), + ) + .filter(line => line && !AD_LINE.test(line)) + .map(line => `

${line}

`); + + if (!paragraphs.length) + throw new Error(`[LeWenn] Chapter content not found: ${url}`); + + return paragraphs.join(''); + } + + // lewenn.com has no search of its own; its search provider + // (so.biqusoso.com) redirects to a region-blocked domain, so search returns + // nothing rather than calling it + async searchNovels(): Promise { + return []; + } +} + +export default new LeWennPlugin(); diff --git a/public/static/src/cn/lewenn/icon.png b/public/static/src/cn/lewenn/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9e8316426abaf8735be3c9fcb07f9f209ceaff75 GIT binary patch literal 3489 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 Date: Thu, 15 Oct 2026 20:04:34 +0800 Subject: [PATCH 2/3] no-mistakes(review): drop chapter fallback and guessed cover URLs --- plugins/chinese/lewenn.ts | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/plugins/chinese/lewenn.ts b/plugins/chinese/lewenn.ts index 7dc1819af..58aa8e291 100644 --- a/plugins/chinese/lewenn.ts +++ b/plugins/chinese/lewenn.ts @@ -23,13 +23,6 @@ const toPath = (href?: string): string | undefined => { } }; -// the site stores cover images at files/article/image///s.jpg -const coverOf = (novelPath: string): string => { - const id = novelPath.match(/^lw(\d+)\//)?.[1]; - if (!id) return defaultCover; - return `${SITE}files/article/image/${Math.floor(Number(id) / 1000)}/${id}/${id}s.jpg`; -}; - // returns the text after a full- or half-width colon, e.g. "作者:水果饺子" const valueAfterColon = (text: string): string => text @@ -83,7 +76,7 @@ class LeWennPlugin implements Plugin.PluginBase { const path = toPath($a.attr('href')); if (!name || !path || !NOVEL_PATH.test(path) || seen.has(path)) return; seen.add(path); - novels.push({ name, path, cover: coverOf(path) }); + novels.push({ name, path, cover: defaultCover }); }); return novels; @@ -135,7 +128,7 @@ class LeWennPlugin implements Plugin.PluginBase { return { path: novelPath, name: getMeta('og:novel:book_name') || $info.find('h1').text().trim(), - cover: getMeta('og:image') || coverOf(novelPath), + cover: getMeta('og:image') || defaultCover, summary: $('#intro') .text() @@ -161,7 +154,6 @@ class LeWennPlugin implements Plugin.PluginBase { $: ReturnType, ): Plugin.ChapterItem[] { const mainText = new Map(); - const everyLink = new Map(); let inMainText = false; $('.listmain dl') @@ -172,20 +164,17 @@ class LeWennPlugin implements Plugin.PluginBase { inMainText = $el.text().includes('正文'); return; } - if (!$el.is('dd')) return; + if (!$el.is('dd') || !inMainText) return; const path = toPath($el.find('a').attr('href')); const name = $el.find('a').text().trim(); if (!path || !name || !CHAPTER_PATH.test(path)) return; - const chapter = { name, path }; - everyLink.set(path, chapter); - if (inMainText) mainText.set(path, chapter); + mainText.set(path, { name, path }); }); - // fall back to every link when the page has no 正文卷 heading const list: Plugin.ChapterItem[] = []; - (mainText.size ? mainText : everyLink).forEach(chapter => + mainText.forEach(chapter => list.push({ ...chapter, chapterNumber: list.length + 1 }), ); return list; From 0bcc8cdd0f4971d008f379300288b92b67dadcd6 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 20:07:52 +0800 Subject: [PATCH 3/3] no-mistakes(review): escape chapter HTML and drop metadata fallbacks --- plugins/chinese/lewenn.ts | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/plugins/chinese/lewenn.ts b/plugins/chinese/lewenn.ts index 58aa8e291..3c37533df 100644 --- a/plugins/chinese/lewenn.ts +++ b/plugins/chinese/lewenn.ts @@ -23,13 +23,6 @@ const toPath = (href?: string): string | undefined => { } }; -// returns the text after a full- or half-width colon, e.g. "作者:水果饺子" -const valueAfterColon = (text: string): string => - text - .replace(/\u00a0/g, ' ') - .replace(/^[^::]*[::]\s*/, '') - .trim(); - class LeWennPlugin implements Plugin.PluginBase { id = 'lewenn'; name = '乐文小说网'; @@ -115,29 +108,18 @@ class LeWennPlugin implements Plugin.PluginBase { const getMeta = (prop: string) => $(`meta[property="${prop}"]`).attr('content')?.trim() || ''; - const $info = $('#info'); - const infoLine = (label: string) => - $info - .find('p') - .filter((_i, p) => $(p).text().includes(label)) - .first() - .text(); - - const statusText = getMeta('og:novel:status') || infoLine('状态'); + const statusText = getMeta('og:novel:status'); return { path: novelPath, - name: getMeta('og:novel:book_name') || $info.find('h1').text().trim(), + name: getMeta('og:novel:book_name'), cover: getMeta('og:image') || defaultCover, summary: $('#intro') .text() .replace(/\u00a0/g, ' ') .trim() || undefined, - author: - getMeta('og:novel:author') || - valueAfterColon(infoLine('作者')) || - undefined, + author: getMeta('og:novel:author') || undefined, genres: getMeta('og:novel:category') || undefined, status: statusText.includes('完结') ? NovelStatus.Completed @@ -201,7 +183,10 @@ class LeWennPlugin implements Plugin.PluginBase { .trim(), ) .filter(line => line && !AD_LINE.test(line)) - .map(line => `

${line}

`); + .map( + line => + `

${line.replace(/&/g, '&').replace(//g, '>')}

`, + ); if (!paragraphs.length) throw new Error(`[LeWenn] Chapter content not found: ${url}`);