Repository navigation
feat(english/webnovel): add fanfic browse and A-Z tag filter #2756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ import { storage } from '@libs/storage'; | |||||||||||||||||||||||
| class Webnovel implements Plugin.PluginBase { | ||||||||||||||||||||||||
| id = 'webnovel'; | ||||||||||||||||||||||||
| name = 'Webnovel'; | ||||||||||||||||||||||||
| version = '1.0.3'; | ||||||||||||||||||||||||
| version = '1.1.0'; | ||||||||||||||||||||||||
| icon = 'src/en/webnovel/icon.png'; | ||||||||||||||||||||||||
| site = 'https://www.webnovel.com'; | ||||||||||||||||||||||||
| headers = { | ||||||||||||||||||||||||
|
|
@@ -72,16 +72,47 @@ class Webnovel implements Plugin.PluginBase { | |||||||||||||||||||||||
| filters, | ||||||||||||||||||||||||
| }: Plugin.PopularNovelsOptions<typeof this.filters>, | ||||||||||||||||||||||||
| ): Promise<Plugin.NovelItem[]> { | ||||||||||||||||||||||||
| const sort = filters?.sort?.value || '1'; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (filters?.fanfic_search.value) { | ||||||||||||||||||||||||
| return this.searchNovelsInternal( | ||||||||||||||||||||||||
| filters.fanfic_search.value, | ||||||||||||||||||||||||
| pageNo, | ||||||||||||||||||||||||
| 'fanfic', | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| let url = this.site + '/stories/'; | ||||||||||||||||||||||||
| const params = new URLSearchParams(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (filters?.tag?.value && filters.tag.value !== '0') { | ||||||||||||||||||||||||
| params.append('orderBy', sort); | ||||||||||||||||||||||||
| params.append('pageIndex', pageNo.toString()); | ||||||||||||||||||||||||
| url = `${this.site}/tags/${filters.tag.value}-novel?${params.toString()}`; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const result = await fetchApi(url, { | ||||||||||||||||||||||||
| headers: this.headers, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| const body = await result.text(); | ||||||||||||||||||||||||
| const loadedCheerio = parseHTML(body); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return this.parseTagNovels(loadedCheerio); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (filters?.browse_type?.value === 'fanfic') { | ||||||||||||||||||||||||
| params.append('orderBy', filters.sort.value); | ||||||||||||||||||||||||
| params.append('pageIndex', pageNo.toString()); | ||||||||||||||||||||||||
| url = this.site + '/stories/fanfic?' + params.toString(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const result = await fetchApi(url, { | ||||||||||||||||||||||||
| headers: this.headers, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| const body = await result.text(); | ||||||||||||||||||||||||
| const loadedCheerio = parseHTML(body); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return this.parseNovels(loadedCheerio, true, false); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (showLatestNovels) { | ||||||||||||||||||||||||
| url += `novel?orderBy=5&pageIndex=${pageNo}`; | ||||||||||||||||||||||||
| } else if (filters) { | ||||||||||||||||||||||||
|
|
@@ -166,6 +197,32 @@ class Webnovel implements Plugin.PluginBase { | |||||||||||||||||||||||
| return chapters; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Tag pages (/tags/{tag}-novel) use a different listing than the bookcity: | ||||||||||||||||||||||||
| * `div.g_book_item` cards whose `a[title]` carries the novel name and path. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| async parseTagNovels(loadedCheerio: CheerioAPI): Promise<Plugin.NovelItem[]> { | ||||||||||||||||||||||||
| return loadedCheerio('.g_book_item') | ||||||||||||||||||||||||
| .map((i_, ele) => { | ||||||||||||||||||||||||
| const anchor = loadedCheerio(ele).find('a[title]').first(); | ||||||||||||||||||||||||
| const novelName = anchor.attr('title') || 'No Title Found'; | ||||||||||||||||||||||||
| const novelCover = | ||||||||||||||||||||||||
| loadedCheerio(ele).find('img').attr('data-original') || | ||||||||||||||||||||||||
| loadedCheerio(ele).find('img').attr('src'); | ||||||||||||||||||||||||
| const novelPath = anchor.attr('href'); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!novelPath) return null; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||
| name: novelName, | ||||||||||||||||||||||||
| cover: novelCover ? 'https:' + novelCover : '', | ||||||||||||||||||||||||
| path: novelPath, | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| .get() | ||||||||||||||||||||||||
| .filter(novel => novel !== null); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { | ||||||||||||||||||||||||
| const url = this.site + novelPath; | ||||||||||||||||||||||||
| const result = await fetchApi(url, { | ||||||||||||||||||||||||
|
|
@@ -329,6 +386,20 @@ class Webnovel implements Plugin.PluginBase { | |||||||||||||||||||||||
| ], | ||||||||||||||||||||||||
| type: FilterTypes.Picker, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| browse_type: { | ||||||||||||||||||||||||
| label: 'Browse', | ||||||||||||||||||||||||
| value: 'novel', | ||||||||||||||||||||||||
| options: [ | ||||||||||||||||||||||||
| { label: 'Novels', value: 'novel' }, | ||||||||||||||||||||||||
| { label: 'Fanfics', value: 'fanfic' }, | ||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||
| type: FilterTypes.Picker, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| tag: { | ||||||||||||||||||||||||
| label: 'Tag (A-Z browse)', | ||||||||||||||||||||||||
| options: TAG_OPTIONS, | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The tag picker has no “All” or empty option. After choosing a tag, users must reset every filter to clear it. Changing Browse to Fanfics still returns the tag page because the tag branch runs first. Add a no-tag option with
Suggested change
|
||||||||||||||||||||||||
| type: FilterTypes.Picker, | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
|
Comment on lines
+398
to
+402
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new
Suggested change
|
||||||||||||||||||||||||
| fanfic_search: { | ||||||||||||||||||||||||
| label: 'Search fanfics (Overrides other filters)', | ||||||||||||||||||||||||
| value: '', | ||||||||||||||||||||||||
|
|
@@ -338,3 +409,89 @@ class Webnovel implements Plugin.PluginBase { | |||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export default new Webnovel(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** Tag slugs currently indexed at webnovel.com/tags, sorted A-Z. */ | ||||||||||||||||||||||||
| const TAG_OPTIONS = [ | ||||||||||||||||||||||||
| { label: 'Abandoned', value: 'abandoned' }, | ||||||||||||||||||||||||
| { label: 'Academy', value: 'academy' }, | ||||||||||||||||||||||||
| { label: 'Action', value: 'action' }, | ||||||||||||||||||||||||
| { label: 'Adventure', value: 'adventure' }, | ||||||||||||||||||||||||
| { label: 'Another World', value: 'anotherworld' }, | ||||||||||||||||||||||||
| { label: 'Antihero', value: 'antihero' }, | ||||||||||||||||||||||||
| { label: 'Apocalypse', value: 'apocalypse' }, | ||||||||||||||||||||||||
| { label: 'Arranged Marriage', value: 'arrangedmarriage' }, | ||||||||||||||||||||||||
| { label: 'Aventura', value: 'aventura' }, | ||||||||||||||||||||||||
| { label: 'Beast Taming', value: 'beast-taming' }, | ||||||||||||||||||||||||
| { label: 'Beauty', value: 'beauty' }, | ||||||||||||||||||||||||
| { label: 'Betrayal', value: 'betrayal' }, | ||||||||||||||||||||||||
| { label: 'Blood Pumping', value: 'bloodpumping' }, | ||||||||||||||||||||||||
| { label: 'Campus', value: 'campus' }, | ||||||||||||||||||||||||
| { label: 'CEO', value: 'ceo' }, | ||||||||||||||||||||||||
| { label: 'Cheats', value: 'cheats' }, | ||||||||||||||||||||||||
| { label: 'Comedy', value: 'comedy' }, | ||||||||||||||||||||||||
| { label: 'Crossdressing', value: 'crossdressing' }, | ||||||||||||||||||||||||
| { label: 'Cultivation', value: 'cultivation' }, | ||||||||||||||||||||||||
| { label: 'Dark', value: 'dark' }, | ||||||||||||||||||||||||
| { label: 'Devil', value: 'devil' }, | ||||||||||||||||||||||||
| { label: 'Disaster', value: 'disaster' }, | ||||||||||||||||||||||||
| { label: 'Doctor', value: 'doctor' }, | ||||||||||||||||||||||||
| { label: 'Dragon', value: 'dragon' }, | ||||||||||||||||||||||||
| { label: 'Ecchi', value: 'ecchi' }, | ||||||||||||||||||||||||
| { label: 'Evolution', value: 'evolution' }, | ||||||||||||||||||||||||
| { label: 'Face Slapping', value: 'faceslapping' }, | ||||||||||||||||||||||||
| { label: 'Fast Paced', value: 'fastpaced' }, | ||||||||||||||||||||||||
| { label: 'Future', value: 'future' }, | ||||||||||||||||||||||||
| { label: 'Genius', value: 'genius' }, | ||||||||||||||||||||||||
| { label: 'Golden Finger', value: 'goldenfinger' }, | ||||||||||||||||||||||||
| { label: 'Harem', value: 'harem' }, | ||||||||||||||||||||||||
| { label: 'High IQ', value: 'highiq' }, | ||||||||||||||||||||||||
| { label: 'Historical', value: 'historical' }, | ||||||||||||||||||||||||
| { label: 'Humor', value: 'humor' }, | ||||||||||||||||||||||||
| { label: 'Immortal', value: 'immortal' }, | ||||||||||||||||||||||||
| { label: 'Isekai', value: 'isekai' }, | ||||||||||||||||||||||||
| { label: 'Level Up', value: 'levelup' }, | ||||||||||||||||||||||||
| { label: 'Love at First Sight', value: 'loveatfirstsight' }, | ||||||||||||||||||||||||
| { label: 'Magic', value: 'magic' }, | ||||||||||||||||||||||||
| { label: 'Martial Arts', value: 'martialarts' }, | ||||||||||||||||||||||||
| { label: 'Mecha', value: 'mecha' }, | ||||||||||||||||||||||||
| { label: 'Military', value: 'military' }, | ||||||||||||||||||||||||
| { label: 'Multiple Leads', value: 'multipleleads' }, | ||||||||||||||||||||||||
| { label: 'Mystery', value: 'mystery' }, | ||||||||||||||||||||||||
| { label: 'Myth', value: 'myth' }, | ||||||||||||||||||||||||
| { label: 'Nonhuman', value: 'nonhuman' }, | ||||||||||||||||||||||||
| { label: 'Ordinary', value: 'ordinary' }, | ||||||||||||||||||||||||
| { label: 'Overpowered', value: 'overpowered' }, | ||||||||||||||||||||||||
| { label: 'Positive', value: 'positive' }, | ||||||||||||||||||||||||
| { label: 'Powerful Couple', value: 'powerfulcouple' }, | ||||||||||||||||||||||||
| { label: 'R18', value: 'r18' }, | ||||||||||||||||||||||||
| { label: 'Rare Bloodline', value: 'rarebloodline' }, | ||||||||||||||||||||||||
| { label: 'Rebirth', value: 'rebirth' }, | ||||||||||||||||||||||||
| { label: 'Reincarnation', value: 'reincarnation' }, | ||||||||||||||||||||||||
| { label: 'Revenge', value: 'revenge' }, | ||||||||||||||||||||||||
| { label: 'Romance', value: 'romance' }, | ||||||||||||||||||||||||
| { label: 'Seductive', value: 'seductive' }, | ||||||||||||||||||||||||
| { label: 'Shapeshifter', value: 'shapeshifter' }, | ||||||||||||||||||||||||
| { label: 'Sistema', value: 'sistema' }, | ||||||||||||||||||||||||
| { label: 'Slice of Life', value: 'sliceoflife' }, | ||||||||||||||||||||||||
| { label: 'Smut', value: 'smut' }, | ||||||||||||||||||||||||
| { label: 'Soldier', value: 'soldier' }, | ||||||||||||||||||||||||
| { label: 'Summons', value: 'summons' }, | ||||||||||||||||||||||||
| { label: 'Supernatural', value: 'supernatural' }, | ||||||||||||||||||||||||
| { label: 'Superpowers', value: 'superpowers' }, | ||||||||||||||||||||||||
| { label: 'Survival', value: 'survival' }, | ||||||||||||||||||||||||
| { label: 'System', value: 'system' }, | ||||||||||||||||||||||||
| { label: 'Teen', value: 'teen' }, | ||||||||||||||||||||||||
| { label: 'The Strong Acting Weak', value: 'thestrongactingweak' }, | ||||||||||||||||||||||||
| { label: 'Time Travel', value: 'timetravel' }, | ||||||||||||||||||||||||
| { label: 'Tragedy', value: 'tragedy' }, | ||||||||||||||||||||||||
| { label: 'Transmigration', value: 'transmigration' }, | ||||||||||||||||||||||||
| { label: 'Unprincipled', value: 'unprincipled' }, | ||||||||||||||||||||||||
| { label: 'Urban', value: 'urban' }, | ||||||||||||||||||||||||
| { label: 'Vampire', value: 'vampire' }, | ||||||||||||||||||||||||
| { label: 'Video Game', value: 'videogame' }, | ||||||||||||||||||||||||
| { label: 'Villain', value: 'villain' }, | ||||||||||||||||||||||||
| { label: 'Weak to Strong', value: 'weaktostrong' }, | ||||||||||||||||||||||||
| { label: 'Werewolf', value: 'werewolf' }, | ||||||||||||||||||||||||
| { label: 'Wizards', value: 'wizards' }, | ||||||||||||||||||||||||
| { label: 'Zero to Hero', value: 'zerotohero' }, | ||||||||||||||||||||||||
| ] as const; | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new tag and fanfic branches return before checking
showLatestNovels. When users select either mode and then switch to Latest, the saved sort remains in effect—orderBy=1by default—instead of the existing latest order,5. HonorshowLatestNovelswhen choosing the order in both branches.