|
| 1 | +import { CheerioAPI, load as parseHTML } from 'cheerio'; |
| 2 | +import { fetchApi } from '@libs/fetch'; |
| 3 | +import { Plugin } from '@typings/plugin'; |
| 4 | +import { defaultCover } from '@libs/defaultCover'; |
| 5 | +import dayjs from 'dayjs'; |
| 6 | +import { Filters, FilterTypes } from '@libs/filterInputs'; |
| 7 | + |
| 8 | +class TsundokuPlugin implements Plugin.PluginBase { |
| 9 | + id = 'tsundoku'; |
| 10 | + name = 'Tsundoku Traduções'; |
| 11 | + version = '1.0.0'; |
| 12 | + icon = 'src/pt-br/tsundoku/icon.png'; |
| 13 | + site = 'https://tsundoku.com.br'; |
| 14 | + |
| 15 | + parseDate(date: string): string { |
| 16 | + const monthMapping: Record<string, number> = { |
| 17 | + janeiro: 1, |
| 18 | + fevereiro: 2, |
| 19 | + marco: 3, |
| 20 | + abril: 4, |
| 21 | + maio: 5, |
| 22 | + junho: 6, |
| 23 | + julho: 7, |
| 24 | + agosto: 8, |
| 25 | + setembro: 9, |
| 26 | + outubro: 10, |
| 27 | + novembro: 11, |
| 28 | + dezembro: 12, |
| 29 | + }; |
| 30 | + const [month, day, year] = date.split(/,?\s+/); |
| 31 | + return dayjs( |
| 32 | + `${year}-${monthMapping[month.normalize('NFD').replace(/[\u0300-\u036f]/g, '')]}-${day}`, |
| 33 | + ).toISOString(); |
| 34 | + } |
| 35 | + |
| 36 | + parseNovels(loadedCheerio: CheerioAPI) { |
| 37 | + const novels: Plugin.NovelItem[] = []; |
| 38 | + |
| 39 | + loadedCheerio('.listupd .bsx').each((idx, ele) => { |
| 40 | + const novelName = loadedCheerio(ele).find('.tt').text().trim(); |
| 41 | + const novelUrl = loadedCheerio(ele).find('a').attr('href'); |
| 42 | + const coverUrl = loadedCheerio(ele).find('img').attr('src'); |
| 43 | + if (!novelUrl) return; |
| 44 | + |
| 45 | + const novel = { |
| 46 | + name: novelName, |
| 47 | + cover: coverUrl || defaultCover, |
| 48 | + path: novelUrl.replace(this.site, ''), |
| 49 | + }; |
| 50 | + |
| 51 | + novels.push(novel); |
| 52 | + }); |
| 53 | + |
| 54 | + return novels; |
| 55 | + } |
| 56 | + |
| 57 | + async popularNovels( |
| 58 | + page: number, |
| 59 | + { |
| 60 | + showLatestNovels, |
| 61 | + filters, |
| 62 | + }: Plugin.PopularNovelsOptions<typeof this.filters>, |
| 63 | + ): Promise<Plugin.NovelItem[]> { |
| 64 | + const params = new URLSearchParams(); |
| 65 | + |
| 66 | + if (page > 1) { |
| 67 | + params.append('page', `${page}`); |
| 68 | + } |
| 69 | + params.append('type', 'novel'); |
| 70 | + |
| 71 | + if (showLatestNovels) { |
| 72 | + params.append('order', 'latest'); |
| 73 | + } else if (filters) { |
| 74 | + if (filters.genre.value.length) { |
| 75 | + filters.genre.value.forEach(value => { |
| 76 | + params.append('genre[]', value); |
| 77 | + }); |
| 78 | + } |
| 79 | + params.append('order', filters.order.value); |
| 80 | + } |
| 81 | + |
| 82 | + const url = `${this.site}/manga/?` + params.toString(); |
| 83 | + |
| 84 | + const body = await fetchApi(url).then(result => result.text()); |
| 85 | + |
| 86 | + const loadedCheerio = parseHTML(body); |
| 87 | + return this.parseNovels(loadedCheerio); |
| 88 | + } |
| 89 | + |
| 90 | + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { |
| 91 | + const body = await fetchApi(this.site + novelPath).then(r => r.text()); |
| 92 | + |
| 93 | + const loadedCheerio = parseHTML(body); |
| 94 | + |
| 95 | + const novel: Plugin.SourceNovel = { |
| 96 | + path: novelPath, |
| 97 | + name: loadedCheerio('h1.entry-title').text() || 'Untitled', |
| 98 | + cover: loadedCheerio('.main-info .thumb img').attr('src'), |
| 99 | + summary: loadedCheerio('.entry-content.entry-content-single div:eq(0)') |
| 100 | + .text() |
| 101 | + .trim(), |
| 102 | + chapters: [], |
| 103 | + }; |
| 104 | + |
| 105 | + novel.author = loadedCheerio('.tsinfo .imptdt:contains("Autor")') |
| 106 | + .text() |
| 107 | + .replace('Autor ', '') |
| 108 | + .trim(); |
| 109 | + |
| 110 | + novel.artist = loadedCheerio('.tsinfo .imptdt:contains("Artista")') |
| 111 | + .text() |
| 112 | + .replace('Artista ', '') |
| 113 | + .trim(); |
| 114 | + |
| 115 | + novel.status = loadedCheerio('.tsinfo .imptdt:contains("Status")') |
| 116 | + .text() |
| 117 | + .replace('Status ', '') |
| 118 | + .trim(); |
| 119 | + |
| 120 | + novel.genres = loadedCheerio('.mgen a') |
| 121 | + .map((_, ex) => loadedCheerio(ex).text()) |
| 122 | + .toArray() |
| 123 | + .join(','); |
| 124 | + |
| 125 | + const chapters: Plugin.ChapterItem[] = []; |
| 126 | + |
| 127 | + loadedCheerio('#chapterlist ul > li').each((idx, ele) => { |
| 128 | + const chapterName = loadedCheerio(ele).find('.chapternum').text().trim(); |
| 129 | + const chapterUrl = loadedCheerio(ele).find('a').attr('href'); |
| 130 | + const releaseDate = loadedCheerio(ele).find('.chapterdate').text(); |
| 131 | + if (!chapterUrl) return; |
| 132 | + |
| 133 | + chapters.push({ |
| 134 | + name: chapterName, |
| 135 | + path: chapterUrl.replace(this.site, ''), |
| 136 | + releaseTime: this.parseDate(releaseDate), |
| 137 | + }); |
| 138 | + }); |
| 139 | + |
| 140 | + novel.chapters = chapters.reverse().map((c, i) => ({ |
| 141 | + ...c, |
| 142 | + name: c.name + ` - Ch. ${i + 1}`, |
| 143 | + chapterNumber: i + 1, |
| 144 | + })); |
| 145 | + return novel; |
| 146 | + } |
| 147 | + |
| 148 | + async searchNovels( |
| 149 | + searchTerm: string, |
| 150 | + pageNo: number, |
| 151 | + ): Promise<Plugin.NovelItem[]> { |
| 152 | + const params = new URLSearchParams(); |
| 153 | + |
| 154 | + if (pageNo > 1) { |
| 155 | + params.append('page', `${pageNo}`); |
| 156 | + } |
| 157 | + params.append('type', 'novel'); |
| 158 | + params.append('title', searchTerm); |
| 159 | + |
| 160 | + const url = `${this.site}/manga/?` + params.toString(); |
| 161 | + |
| 162 | + const body = await fetchApi(url).then(result => result.text()); |
| 163 | + |
| 164 | + const loadedCheerio = parseHTML(body); |
| 165 | + return this.parseNovels(loadedCheerio); |
| 166 | + } |
| 167 | + |
| 168 | + async parseChapter(chapterPath: string): Promise<string> { |
| 169 | + const body = await fetchApi(this.site + chapterPath).then(r => r.text()); |
| 170 | + const loadedCheerio = parseHTML(body); |
| 171 | + |
| 172 | + const chapterTitle = loadedCheerio('.headpost .entry-title').text(); |
| 173 | + const novelTitle = loadedCheerio('.headpost a').text(); |
| 174 | + const title = chapterTitle |
| 175 | + .replace(novelTitle, '') |
| 176 | + .replace(/^\W+/, '') |
| 177 | + .trim(); |
| 178 | + |
| 179 | + const spoilerContent = loadedCheerio( |
| 180 | + '#readerarea .collapseomatic_content', |
| 181 | + ).html(); |
| 182 | + if (spoilerContent) { |
| 183 | + return `<h1>${title}</h1>\n${spoilerContent}`; |
| 184 | + } |
| 185 | + |
| 186 | + const $readerarea = loadedCheerio('#readerarea'); |
| 187 | + $readerarea.find('img.wp-image-15656').remove(); // Remove logo messages |
| 188 | + |
| 189 | + // Remove empty paragraphs |
| 190 | + $readerarea.find('p').each((i, el) => { |
| 191 | + const $this = loadedCheerio(el); |
| 192 | + const $imgs = $this.find('img'); |
| 193 | + const cleanContent = $this |
| 194 | + .text() |
| 195 | + ?.replace(/\s| /g, '') |
| 196 | + ?.replace(this.site, ''); |
| 197 | + |
| 198 | + // Without images and empty content |
| 199 | + if ($imgs?.length === 0 && cleanContent?.length === 0) { |
| 200 | + $this.remove(); |
| 201 | + } |
| 202 | + }); |
| 203 | + |
| 204 | + const chapterText = $readerarea.html() || ''; |
| 205 | + const parts = chapterText.split(/<hr ?\/?>/); |
| 206 | + if ( |
| 207 | + parts.length > 1 && |
| 208 | + parts[parts.length - 1].includes( |
| 209 | + 'Agradecemos a todos que leram diretamente aqui', |
| 210 | + ) |
| 211 | + ) { |
| 212 | + parts.pop(); |
| 213 | + } |
| 214 | + |
| 215 | + return `<h1>${title}</h1>\n${parts.join('<hr />')}`; |
| 216 | + } |
| 217 | + |
| 218 | + filters = { |
| 219 | + order: { |
| 220 | + label: 'Ordenar por', |
| 221 | + value: '', |
| 222 | + options: [ |
| 223 | + { label: 'Padrão', value: '' }, |
| 224 | + { label: 'A-Z', value: 'title' }, |
| 225 | + { label: 'Z-A', value: 'titlereverse' }, |
| 226 | + { label: 'Atualizar', value: 'update' }, |
| 227 | + { label: 'Adicionar', value: 'latest' }, |
| 228 | + { label: 'Popular', value: 'popular' }, |
| 229 | + ], |
| 230 | + type: FilterTypes.Picker, |
| 231 | + }, |
| 232 | + genre: { |
| 233 | + label: 'Gênero', |
| 234 | + value: [], |
| 235 | + options: [ |
| 236 | + { label: 'Ação', value: '328' }, |
| 237 | + { label: 'Adult', value: '343' }, |
| 238 | + { label: 'Anatomia', value: '408' }, |
| 239 | + { label: 'Artes Marciais', value: '340' }, |
| 240 | + { label: 'Aventura', value: '315' }, |
| 241 | + { label: 'Ciência', value: '398' }, |
| 242 | + { label: 'Comédia', value: '322' }, |
| 243 | + { label: 'Comédia Romântica', value: '378' }, |
| 244 | + { label: 'Cotidiano', value: '399' }, |
| 245 | + { label: 'Drama', value: '311' }, |
| 246 | + { label: 'Ecchi', value: '329' }, |
| 247 | + { label: 'Fantasia', value: '316' }, |
| 248 | + { label: 'Feminismo', value: '362' }, |
| 249 | + { label: 'Gender Bender', value: '417' }, |
| 250 | + { label: 'Guerra', value: '368' }, |
| 251 | + { label: 'Harém', value: '350' }, |
| 252 | + { label: 'Hentai', value: '344' }, |
| 253 | + { label: 'História', value: '400' }, |
| 254 | + { label: 'Histórico', value: '380' }, |
| 255 | + { label: 'Horror', value: '317' }, |
| 256 | + { label: 'Humor Negro', value: '363' }, |
| 257 | + { label: 'Isekai', value: '318' }, |
| 258 | + { label: 'Josei', value: '356' }, |
| 259 | + { label: 'Joshikousei', value: '364' }, |
| 260 | + { label: 'LitRPG', value: '387' }, |
| 261 | + { label: 'Maduro', value: '351' }, |
| 262 | + { label: 'Mágia', value: '372' }, |
| 263 | + { label: 'Mecha', value: '335' }, |
| 264 | + { label: 'Militar', value: '414' }, |
| 265 | + { label: 'Mistério', value: '319' }, |
| 266 | + { label: 'Otaku', value: '365' }, |
| 267 | + { label: 'Psicológico', value: '320' }, |
| 268 | + { label: 'Reencarnação', value: '358' }, |
| 269 | + { label: 'Romance', value: '312' }, |
| 270 | + { label: 'RPG', value: '366' }, |
| 271 | + { label: 'Sátira', value: '367' }, |
| 272 | + { label: 'Sci-fi', value: '371' }, |
| 273 | + { label: 'Seinen', value: '326' }, |
| 274 | + { label: 'Sexo Explícito', value: '345' }, |
| 275 | + { label: 'Shoujo', value: '323' }, |
| 276 | + { label: 'Shounen', value: '341' }, |
| 277 | + { label: 'Slice-of-Life', value: '324' }, |
| 278 | + { label: 'Sobrenatural', value: '359' }, |
| 279 | + { label: 'Supernatural', value: '401' }, |
| 280 | + { label: 'Suspense', value: '407' }, |
| 281 | + { label: 'Thriller', value: '410' }, |
| 282 | + { label: 'Tragédia', value: '352' }, |
| 283 | + { label: 'Vida Escolar', value: '331' }, |
| 284 | + { label: 'Webtoon', value: '381' }, |
| 285 | + { label: 'Xianxia', value: '357' }, |
| 286 | + { label: 'Xuanhuan', value: '395' }, |
| 287 | + { label: 'Yuri', value: '313' }, |
| 288 | + ], |
| 289 | + type: FilterTypes.CheckboxGroup, |
| 290 | + }, |
| 291 | + } satisfies Filters; |
| 292 | +} |
| 293 | + |
| 294 | +export default new TsundokuPlugin(); |
0 commit comments