|
| 1 | +import { Plugin } from '@typings/plugin'; |
| 2 | +import { fetchApi, fetchFile } from '@libs/fetch'; |
| 3 | +import { CheerioAPI, load as parseHTML } from 'cheerio'; |
| 4 | +import qs from 'qs'; |
| 5 | +import { Filters, FilterTypes } from '@libs/filterInputs'; |
| 6 | + |
| 7 | +class LibReadPlugin implements Plugin.PluginBase { |
| 8 | + id = 'libread'; |
| 9 | + name = 'Lib Read'; |
| 10 | + icon = 'src/en/libread/icon.png'; |
| 11 | + site = 'https://libread.org'; |
| 12 | + version = '1.0.0'; |
| 13 | + |
| 14 | + async getCheerio(url: string): Promise<CheerioAPI> { |
| 15 | + const r = await fetchApi(url); |
| 16 | + if (!r.ok) |
| 17 | + throw new Error( |
| 18 | + `Could not reach site (${r.status}: ${r.statusText}) try to open in webview.`, |
| 19 | + ); |
| 20 | + return parseHTML(await r.text()); |
| 21 | + } |
| 22 | + |
| 23 | + parseNovels(loadedCheerio: CheerioAPI): Plugin.NovelItem[] { |
| 24 | + return loadedCheerio('.li-row') |
| 25 | + .map((index, element) => ({ |
| 26 | + name: loadedCheerio(element).find('.tit').text() || '', |
| 27 | + cover: loadedCheerio(element).find('img').attr('src'), |
| 28 | + path: loadedCheerio(element).find('h3 > a').attr('href') || '', |
| 29 | + })) |
| 30 | + .get() |
| 31 | + .filter(novel => novel.name && novel.path); |
| 32 | + } |
| 33 | + |
| 34 | + async popularNovels( |
| 35 | + page: number, |
| 36 | + { |
| 37 | + showLatestNovels, |
| 38 | + filters, |
| 39 | + }: Plugin.PopularNovelsOptions<typeof this.filters>, |
| 40 | + ): Promise<Plugin.NovelItem[]> { |
| 41 | + let url = this.site; |
| 42 | + if (showLatestNovels) url += '/sort/latest-novels/'; |
| 43 | + else { |
| 44 | + if ( |
| 45 | + filters && |
| 46 | + filters.type_genre && |
| 47 | + filters.type_genre.value !== 'all' && |
| 48 | + filters.type_genre.value !== 'genre' |
| 49 | + ) |
| 50 | + url += filters.type_genre.value; |
| 51 | + else { |
| 52 | + url += '/most-popular/'; |
| 53 | + if (page != 1) return []; |
| 54 | + page = 0; |
| 55 | + } |
| 56 | + } |
| 57 | + url += page; |
| 58 | + |
| 59 | + const $ = await this.getCheerio(url); |
| 60 | + return this.parseNovels($); |
| 61 | + } |
| 62 | + |
| 63 | + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { |
| 64 | + const loadedCheerio = await this.getCheerio(this.site + novelPath); |
| 65 | + |
| 66 | + const novel: Plugin.SourceNovel = { |
| 67 | + path: novelPath, |
| 68 | + name: loadedCheerio('h1.tit').text(), |
| 69 | + cover: loadedCheerio('.pic > img').attr('src'), |
| 70 | + summary: loadedCheerio('.inner').text().trim(), |
| 71 | + }; |
| 72 | + |
| 73 | + novel.genres = loadedCheerio('[title=Genre]') |
| 74 | + .next() |
| 75 | + .text() |
| 76 | + .replace(/[\t\n]/g, ''); |
| 77 | + |
| 78 | + novel.author = loadedCheerio('[title=Author]') |
| 79 | + .next() |
| 80 | + .text() |
| 81 | + .replace(/[\t\n]/g, ''); |
| 82 | + |
| 83 | + novel.status = loadedCheerio('[title=Status]') |
| 84 | + .next() |
| 85 | + .text() |
| 86 | + .replace(/[\t\n]/g, ''); |
| 87 | + |
| 88 | + novel.genres = loadedCheerio('[title=Genre]') |
| 89 | + .next() |
| 90 | + .text() |
| 91 | + .trim() |
| 92 | + .replace(/[\t\n]/g, ',') |
| 93 | + .replace(/, /g, ','); |
| 94 | + |
| 95 | + const chapters: Plugin.ChapterItem[] = loadedCheerio('#idData > li > a') |
| 96 | + .map((chapterIndex, element) => ({ |
| 97 | + name: |
| 98 | + loadedCheerio(element).attr('title') || |
| 99 | + 'Chapter ' + (chapterIndex + 1), |
| 100 | + path: |
| 101 | + loadedCheerio(element).attr('href') || |
| 102 | + novelPath.replace( |
| 103 | + '.html', |
| 104 | + '/chapter-' + (chapterIndex + 1) + '.html', |
| 105 | + ), |
| 106 | + releaseTime: null, |
| 107 | + chapterNumber: chapterIndex + 1, |
| 108 | + })) |
| 109 | + .get(); |
| 110 | + |
| 111 | + novel.chapters = chapters; |
| 112 | + return novel; |
| 113 | + } |
| 114 | + |
| 115 | + async parseChapter(chapterPath: string): Promise<string> { |
| 116 | + const loadedCheerio = await this.getCheerio(this.site + chapterPath); |
| 117 | + |
| 118 | + // remove freewebnovel signature |
| 119 | + if (loadedCheerio('style').text().includes('p:nth-last-child(1)')) |
| 120 | + loadedCheerio('div.txt').find('p:last-child').remove(); |
| 121 | + |
| 122 | + const chapterText = loadedCheerio('div.txt').html() || ''; |
| 123 | + return chapterText; |
| 124 | + } |
| 125 | + |
| 126 | + async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> { |
| 127 | + const r = await fetchApi(this.site + '/search/', { |
| 128 | + headers: { |
| 129 | + 'Content-Type': 'application/x-www-form-urlencoded', |
| 130 | + Referer: this.site, |
| 131 | + Origin: this.site, |
| 132 | + }, |
| 133 | + method: 'POST', |
| 134 | + body: qs.stringify({ searchkey: searchTerm }), |
| 135 | + }); |
| 136 | + if (!r.ok) |
| 137 | + throw new Error( |
| 138 | + 'Could not reach site (' + r.status + ') try to open in webview.', |
| 139 | + ); |
| 140 | + |
| 141 | + const loadedCheerio = parseHTML(await r.text()); |
| 142 | + const alertText = |
| 143 | + loadedCheerio('script') |
| 144 | + .text() |
| 145 | + .match(/alert\((.*?)\)/)?.[1] || ''; |
| 146 | + if (alertText) throw new Error(alertText); |
| 147 | + |
| 148 | + return this.parseNovels(loadedCheerio); |
| 149 | + } |
| 150 | + |
| 151 | + fetchImage = fetchFile; |
| 152 | + |
| 153 | + filters = { |
| 154 | + type_genre: { |
| 155 | + type: FilterTypes.Picker, |
| 156 | + label: 'Novel Type/Genre', |
| 157 | + value: 'all', |
| 158 | + options: [ |
| 159 | + { label: 'Tous', value: 'all' }, |
| 160 | + { label: '═══NOVEL TYPES═══', value: '/sort/latest-release/' }, |
| 161 | + { |
| 162 | + label: 'Chinese Novel', |
| 163 | + value: '/sort/latest-release/chinese-novel/', |
| 164 | + }, |
| 165 | + { label: 'Korean Novel', value: '/sort/latest-release/korean-novel/' }, |
| 166 | + { |
| 167 | + label: 'Japanese Novel', |
| 168 | + value: '/sort/latest-release/japanese-novel/', |
| 169 | + }, |
| 170 | + { |
| 171 | + label: 'English Novel', |
| 172 | + value: '/sort/latest-release/english-novel/', |
| 173 | + }, |
| 174 | + { label: '═══GENRES═══', value: 'genre' }, |
| 175 | + { label: 'Action', value: '/genre/Action/' }, |
| 176 | + { label: 'Adult', value: '/genre/Adult/' }, |
| 177 | + { label: 'Adventure', value: '/genre/Adventure/' }, |
| 178 | + { label: 'Comedy', value: '/genre/Comedy/' }, |
| 179 | + { label: 'Drama', value: '/genre/Drama/' }, |
| 180 | + { label: 'Eastern', value: '/genre/Eastern/' }, |
| 181 | + { label: 'Ecchi', value: '/genre/Ecchi/' }, |
| 182 | + { label: 'Fantasy', value: '/genre/Fantasy/' }, |
| 183 | + { label: 'Game', value: '/genre/Game/' }, |
| 184 | + { label: 'Gender Bender', value: '/genre/Gender+Bender/' }, |
| 185 | + { label: 'Harem', value: '/genre/Harem/' }, |
| 186 | + { label: 'Historical', value: '/genre/Historical/' }, |
| 187 | + { label: 'Horror', value: '/genre/Horror/' }, |
| 188 | + { label: 'Josei', value: '/genre/Josei/' }, |
| 189 | + { label: 'Martial Arts', value: '/genre/Martial+Arts/' }, |
| 190 | + { label: 'Mature', value: '/genre/Mature/' }, |
| 191 | + { label: 'Mecha', value: '/genre/Mecha/' }, |
| 192 | + { label: 'Mystery', value: '/genre/Mystery/' }, |
| 193 | + { label: 'Psychological', value: '/genre/Psychological/' }, |
| 194 | + { label: 'Reincarnation', value: '/genre/Reincarnation/' }, |
| 195 | + { label: 'Romance', value: '/genre/Romance/' }, |
| 196 | + { label: 'School Life', value: '/genre/School+Life/' }, |
| 197 | + { label: 'Sci-fi', value: '/genre/Sci-fi/' }, |
| 198 | + { label: 'Seinen', value: '/genre/Seinen/' }, |
| 199 | + { label: 'Shoujo', value: '/genre/Shoujo/' }, |
| 200 | + { label: 'Shounen Ai', value: '/genre/Shounen+Ai/' }, |
| 201 | + { label: 'Shounen', value: '/genre/Shounen/' }, |
| 202 | + { label: 'Slice of Life', value: '/genre/Slice+of+Life/' }, |
| 203 | + { label: 'Smut', value: '/genre/Smut/' }, |
| 204 | + { label: 'Sports', value: '/genre/Sports/' }, |
| 205 | + { label: 'Supernatural', value: '/genre/Supernatural/' }, |
| 206 | + { label: 'Tragedy', value: '/genre/Tragedy/' }, |
| 207 | + { label: 'Wuxia', value: '/genre/Wuxia/' }, |
| 208 | + { label: 'Xianxia', value: '/genre/Xianxia/' }, |
| 209 | + { label: 'Xuanhuan', value: '/genre/Xuanhuan/' }, |
| 210 | + { label: 'Yaoi', value: '/genre/Yaoi/' }, |
| 211 | + ], |
| 212 | + }, |
| 213 | + } satisfies Filters; |
| 214 | +} |
| 215 | + |
| 216 | +export default new LibReadPlugin(); |
0 commit comments