|
| 1 | +import { CheerioAPI, load as parseHTML } from 'cheerio'; |
| 2 | +import { fetchApi } from '@libs/fetch'; |
| 3 | +import { NovelStatus } from '@libs/novelStatus'; |
| 4 | +import { Plugin } from '@/types/plugin'; |
| 5 | + |
| 6 | +class WitchCultTranslations implements Plugin.PluginBase { |
| 7 | + id = 'witchculttranslations'; |
| 8 | + name = 'Witch Cult Translations'; |
| 9 | + site = 'https://witchculttranslation.com'; |
| 10 | + icon = 'src/en/wct/icon.png'; |
| 11 | + version = '1.0.0'; |
| 12 | + |
| 13 | + private cachedNovel: Plugin.NovelItem | null = null; |
| 14 | + |
| 15 | + private async novel(): Promise<Plugin.NovelItem> { |
| 16 | + if (this.cachedNovel !== null) { |
| 17 | + return this.cachedNovel; |
| 18 | + } |
| 19 | + |
| 20 | + const result = await fetchApi(this.site); |
| 21 | + const body = await result.text(); |
| 22 | + const loadedCheerio = parseHTML(body); |
| 23 | + |
| 24 | + const latestArcCover = loadedCheerio('.entry-content h1 img') |
| 25 | + .last() |
| 26 | + .attr('src'); |
| 27 | + |
| 28 | + this.cachedNovel = { |
| 29 | + name: 'Re:Zero kara Hajimeru Isekai Seikatsu', |
| 30 | + path: '/table-of-content', |
| 31 | + cover: latestArcCover, |
| 32 | + }; |
| 33 | + |
| 34 | + return this.cachedNovel; |
| 35 | + } |
| 36 | + |
| 37 | + async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> { |
| 38 | + const novels = []; |
| 39 | + if (pageNo === 1) { |
| 40 | + novels.push(await this.novel()); |
| 41 | + } |
| 42 | + |
| 43 | + return novels; |
| 44 | + } |
| 45 | + |
| 46 | + async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> { |
| 47 | + const novels = [await this.novel()]; |
| 48 | + |
| 49 | + const q = this.normalize(searchTerm); |
| 50 | + |
| 51 | + return novels.filter(({ name }) => this.normalize(name).includes(q)); |
| 52 | + } |
| 53 | + |
| 54 | + private normalize(str: string) { |
| 55 | + return str.toLowerCase().replace(/[^a-z0-9]/g, ''); |
| 56 | + } |
| 57 | + |
| 58 | + async parseChapter(chapterPath: string): Promise<string> { |
| 59 | + const result = await fetchApi(this.site + chapterPath); |
| 60 | + const body = await result.text(); |
| 61 | + const loadedCheerio = parseHTML(body); |
| 62 | + |
| 63 | + const title = loadedCheerio('h1.entry-title').text().trim(); |
| 64 | + const content = loadedCheerio('.entry-content').first(); |
| 65 | + content |
| 66 | + .find('#patreon-snippet, .sharedaddy, .jp-relatedposts, #jp-post-flair') |
| 67 | + .remove(); |
| 68 | + |
| 69 | + return `<h1>${title}</h1>${content.html() || ''}`; |
| 70 | + } |
| 71 | + |
| 72 | + async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { |
| 73 | + const [body, novel] = await Promise.all([ |
| 74 | + fetchApi(this.site + novelPath).then(result => result.text()), |
| 75 | + this.novel(), |
| 76 | + ]); |
| 77 | + |
| 78 | + const loadedCheerio = parseHTML(body); |
| 79 | + |
| 80 | + return { |
| 81 | + ...novel, |
| 82 | + author: 'Tappei Nagatsuki', |
| 83 | + chapters: this.parseChaptersFromTOC(loadedCheerio), |
| 84 | + status: NovelStatus.Ongoing, |
| 85 | + summary: |
| 86 | + 'Fan translation of the Re:Zero web novel (Arc 5 onwards).\n\nSuddenly, Natsuki Subaru, a shut-in student, is summoned to another world on his way home from the convenience store. A completely ordinary person with no knowledge, skills, combat abilities, or communication skills, he\'s thrown into this other world without any cheat bonuses and must desperately try to survive. The only blessing he receives is the painful ability to "return by death," which allows him to rewind time after dying! In this other world where he has no one to rely on, how many times will he die, and what will he ultimately gain?', |
| 87 | + }; |
| 88 | + } |
| 89 | + |
| 90 | + private parseChaptersFromTOC( |
| 91 | + loadedCheerio: CheerioAPI, |
| 92 | + ): Plugin.ChapterItem[] { |
| 93 | + const chapters: Plugin.ChapterItem[] = []; |
| 94 | + let currentArc = 0; |
| 95 | + let chapterNumber = 0; |
| 96 | + |
| 97 | + const children = loadedCheerio('.entry-content') |
| 98 | + .first() |
| 99 | + .children() |
| 100 | + .toArray(); |
| 101 | + |
| 102 | + for (const el of children) { |
| 103 | + if (el.type !== 'tag') continue; |
| 104 | + const tag = el.tagName.toLowerCase(); |
| 105 | + |
| 106 | + if (tag === 'h1' || tag === 'h2') { |
| 107 | + const text = loadedCheerio(el).text().trim(); |
| 108 | + const arcMatch = text.match(/^Arc\s+(\d+)/i); |
| 109 | + if (arcMatch) { |
| 110 | + currentArc = parseInt(arcMatch[1], 10); |
| 111 | + continue; |
| 112 | + } |
| 113 | + if (/^Side Content/i.test(text)) { |
| 114 | + break; |
| 115 | + } |
| 116 | + continue; |
| 117 | + } |
| 118 | + |
| 119 | + if (tag !== 'ul' || currentArc < 5) continue; |
| 120 | + |
| 121 | + loadedCheerio(el) |
| 122 | + .find('li > a') |
| 123 | + .each((_, a) => { |
| 124 | + const href = loadedCheerio(a).attr('href'); |
| 125 | + if (!href) return; |
| 126 | + |
| 127 | + const onSite = |
| 128 | + /^https?:\/\/(?:www\.)?witchculttranslation\.com\//i.test(href); |
| 129 | + if (!onSite) return; |
| 130 | + |
| 131 | + const name = loadedCheerio(a).text().trim(); |
| 132 | + if (!name) return; |
| 133 | + |
| 134 | + const path = `/${href |
| 135 | + .replace(/^https?:\/\/(?:www\.)?witchculttranslation\.com\//i, '') |
| 136 | + .replace(/^\/+/, '')}`; |
| 137 | + |
| 138 | + const dateMatch = path.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\//); |
| 139 | + const releaseTime = dateMatch |
| 140 | + ? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}` |
| 141 | + : null; |
| 142 | + |
| 143 | + chapterNumber += 1; |
| 144 | + chapters.push({ |
| 145 | + name: `Arc ${currentArc}, ${name}`, |
| 146 | + path, |
| 147 | + releaseTime, |
| 148 | + chapterNumber, |
| 149 | + }); |
| 150 | + }); |
| 151 | + } |
| 152 | + |
| 153 | + return chapters; |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +export default new WitchCultTranslations(); |
0 commit comments