From e7caaaeee25fa72830cc76af66a128b852fcd52e Mon Sep 17 00:00:00 2001 From: q144255 Date: Sun, 1 Dec 2024 15:22:40 +0900 Subject: [PATCH] feat: add kakuyomu plugin (#1348) --- public/static/src/jp/kakuyomu/icon.png | Bin 0 -> 2932 bytes src/plugins/japanese/kakuyomu.ts | 277 +++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 public/static/src/jp/kakuyomu/icon.png create mode 100644 src/plugins/japanese/kakuyomu.ts diff --git a/public/static/src/jp/kakuyomu/icon.png b/public/static/src/jp/kakuyomu/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5eb0b8ea362d4318735227ebada45c0e520f2842 GIT binary patch literal 2932 zcmcImdsI{R9lv?-IM~XgLSwbz8lO2MPY5I_fsh0c+Mo*$Q|W+^Tp$g}jmgC%Xpa}q zY?Y!xvBgJG+m*wPLnpe@DJ`!8!Vu-?lnMq;LBm9R;22DxW;bAH{wQUeTh7hN@AEz1 z_xJPt-R#?(6#uHv8$JL4yqd64A_D+isq^dYiAm6g@GIEQawJxcB*Qs~NnwD17!{li zfeBhgE+m5#D$BMGC=vh&8`LRsL@s@YuY|R9g;R%a*6J~90Emn>>lI23gn-#lu39Ic zJg#e?fNGV1vV|jMO7&tWPrb3w03{bDr6>zEN}h@m9R)_3`IrMOgeX9>HeYAtn+23v zzkKZM6f-E`tO}wLQ09`7OE-gJ*Z_eXdN@tV3gv zV#RW~A~BB_7s?7_^F-k>T#?IG0xJu&kPdO#s(!MKUC`D^2(2EAEP)K_?T{+Y0Bgb7 z!ujfja}h1*?}e>u;atQE+A^?Y7|!v!joT%{IOIIelNST?^oMj9=M5OSC-T#{0N^#4 zAc;va_m0|{G{>Qpjbjl3+t1njE(>!#0^cLXsP}+Z`wDwomA)CCOF!opJJ8onPx7~o z4?ai9rgK#jb|JoY{r&SI|9sQ^>!Pnyz4rWbhwUv=W%g@j>nta)d6E02%rN*waRffR_@bd$RL;!~q3IXi-f1Mwg%2Dl{thYG z^t7SP9NKo|KEA^oFk~flkxa^vh~W`UMbySR8+~$W>(qL4Gb#yd8)B{)DlB=sKf2L> zQaM9XuF8HY7ZTU_#y_Wy#+AU?-;4Hu|YeRxG(g4{d&!?&O-MsG1vevC-zg<&%uJ|}{mvvkJjOSE!;Lw?w9C5n*9`je^ zMk+6}npat#D6D5z;WP(O>ggqO_2C5@P&tQDlyfi=aX* z%#%Bl4f(kZQRcAm%wc53W_${Mfz^NNNcRa!=J&^IRE=Aim z7X9y03)THjSfrX$xu=tw^4`6Dr?YnQC>-O|EcexA-XAX?H{@#9<>HL*FN!B*TtzF zP-bTGGgg0}Jv6He+w+i?yrVZ7sd<6e6>)g*!rk`-4$J6qJ4f%M3v!Cu&yY0s@a}kx z^SV*t0|xw8wkg&bbNSuD&wAO()f~L6d2jwQe_mKm5t$z{Saa}tS<<6&YTJ@;mVKn5 z;K!Ndp5m|8QWNfY89wXXIrRR@AZ5vMhp_wvoc`tt-^rrG(`D@`_GW%}#m*LLyx!IQto_H*)xsf7NynX{584GzAfc^x;*}-eZ{l1TlpmqSagdOh;JCaa>Ayl z*I%EGOl+w4X|G`&Ex}#^owqLxe9k#|F%3`cAk?HvRy)pI>h#}#;OjaMB5>e`0g0E} nI2PgSOKbio`-YyFN=I=3)q`To2qr&p{tr%wO_H1yW$peKN(N=u literal 0 HcmV?d00001 diff --git a/src/plugins/japanese/kakuyomu.ts b/src/plugins/japanese/kakuyomu.ts new file mode 100644 index 0000000..71359dd --- /dev/null +++ b/src/plugins/japanese/kakuyomu.ts @@ -0,0 +1,277 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@typings/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +class KakuyomuPlugin implements Plugin.PluginBase { + id = 'kakuyomu'; + name = 'kakuyomu'; + icon = 'src/jp/kakuyomu/icon.png'; + site = 'https://kakuyomu.jp'; + version = '1.0.0'; + filters = { + genre: { + type: FilterTypes.Picker, + label: 'Genre', + options: [ + { label: '総合', value: 'all' }, + { label: '異世界ファンタジー', value: 'fantasy' }, + { label: '現代ファンタジー', value: 'action' }, + { label: 'SF', value: 'sf' }, + { label: '恋愛', value: 'love_story' }, + { label: 'ラブコメ', value: 'romance' }, + { label: '現代ドラマ', value: 'drama' }, + { label: 'ホラー', value: 'horror' }, + { label: 'ミステリー', value: 'mystery' }, + { label: 'エッセイ・ノンフィクション', value: 'nonfiction' }, + { label: '歴史・時代・伝奇', value: 'history' }, + { label: '創作論・評論', value: 'criticism' }, + { label: '詩・童話・その他', value: 'others' }, + ], + value: 'all', + }, + period: { + type: FilterTypes.Picker, + label: 'Period', + options: [ + { label: '累計', value: 'entire' }, + { label: '日間', value: 'daily' }, + { label: '週間', value: 'weekly' }, + { label: '月間', value: 'monthly' }, + { label: '年間', value: 'yearly' }, + ], + value: 'entire', + }, + } satisfies Filters; + imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; + + //flag indicates whether access to LocalStorage, SesesionStorage is required. + webStorageUtilized?: boolean; + + async popularNovels( + pageNo: number, + { filters }: Plugin.PopularNovelsOptions, + ): Promise { + const url = new URL( + `/rankings/${filters.genre.value}/${filters.period.value}`, + this.site, + ); + if (pageNo > 1) { + url.searchParams.set('page', pageNo.toString()); + } + const html = await fetchText(url.toString()); + const $ = loadCheerio(html); + const novels: Plugin.NovelItem[] = []; + + $('.widget-media-genresWorkList-right > .widget-work').each((_, elem) => { + const anchor = $(elem).find('a.widget-workCard-titleLabel'); + const path = anchor.attr('href'); + if (!path) return; + const name = anchor.text(); + novels.push({ + name, + path, + cover: defaultCover, + }); + }); + + return novels; + } + + async parseNovel(novelPath: string): Promise { + const url = new URL(novelPath, this.site); + const html = await fetchText(url.toString()); + const $ = loadCheerio(html); + + const json = JSON.parse( + $('script#__NEXT_DATA__[type="application/json"]').html() || '{}', + ); + const apolloState = json?.props?.pageProps?.__APOLLO_STATE__ ?? {}; + + const work = Object.values(apolloState).find( + v => + typeof v === 'object' && + v !== null && + '__typename' in v && + v.__typename === 'Work' && + 'id' in v && + v.id === novelPath.replace('/works/', ''), + ) as Work; + + const author = Object.values(apolloState).find( + v => + typeof v === 'object' && + v !== null && + '__typename' in v && + v.__typename === 'UserAccount' && + 'id' in v && + v.id === work.author.__ref.replace('UserAccount:', ''), + ) as UserAccount; + + const chapters = Object.values(apolloState).filter(v => { + if ( + typeof v === 'object' && + v !== null && + '__typename' in v && + v.__typename === 'Chapter' + ) { + return true; + } + }) as Chapter[]; + + const tableOfContentsChapter = Object.values(apolloState).filter(v => { + if ( + typeof v === 'object' && + v !== null && + '__typename' in v && + v.__typename === 'TableOfContentsChapter' + ) { + return true; + } + }) as TableOfContentsChapter[]; + + const episodes = Object.values(apolloState).filter(v => { + if ( + typeof v === 'object' && + v !== null && + '__typename' in v && + v.__typename === 'Episode' + ) { + return true; + } + }) as Episode[]; + + const joinChapters: { + chapter: Chapter | undefined; + episode: Episode; + }[] = episodes.map(v => { + const chapter = tableOfContentsChapter.find(c => + c.episodeUnions.some(e => e.__ref === `Episode:${v.id}`), + )?.chapter; + return { + chapter: chapters.find( + c => c.id === chapter?.__ref.replace('Chapter:', ''), + ), + episode: v, + }; + }); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: work.title, + cover: work.adminCoverImageUrl ?? defaultCover, + genres: (work?.tagLabels ?? []).join(','), + author: author?.activityName, + status: + work.serialStatus === 'COMPLETED' + ? NovelStatus.Completed + : NovelStatus.Ongoing, + summary: work.introduction, + chapters: joinChapters.map(v => { + return { + name: v.chapter?.title + ? `${v.chapter?.title} - ${v.episode?.title}` + : v.episode?.title ?? '', + path: `${novelPath}/episodes/${v.episode?.id}`, + releaseTime: new Date(v.episode?.publishedAt ?? 0).toISOString(), + }; + }), + }; + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const url = new URL(chapterPath, this.site); + const html = await fetchText(url.toString()); + const $ = loadCheerio(html); + const chapterTitle = $('.chapterTitle').text() ?? ''; + const episodeTitle = $('.widget-episodeTitle').html() ?? ''; + const episodeBody = $('.widget-episodeBody').html() ?? ''; + const chapterText = ` +
+ ${chapterTitle ? `

${chapterTitle}

` : ''} +

${episodeTitle}

+
+



+ ${episodeBody}`; + return chapterText; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const url = new URL('/search', this.site); + url.searchParams.set('q', searchTerm); + if (pageNo > 1) { + url.searchParams.set('page', pageNo.toString()); + } + const html = await fetchText(url.toString()); + const $ = loadCheerio(html); + + const json = JSON.parse( + $('script#__NEXT_DATA__[type="application/json"]').html() || '{}', + ); + const works = Object.values( + json?.props?.pageProps?.__APOLLO_STATE__ ?? {}, + ).filter(v => { + if ( + typeof v === 'object' && + v !== null && + '__typename' in v && + v.__typename === 'Work' + ) { + return true; + } + }) as Work[]; + + const novels: Plugin.NovelItem[] = works.map(v => ({ + name: v.title ?? '', + path: `/works/${v.id}`, + cover: v.adminCoverImageUrl ?? defaultCover, + })); + + return novels; + } +} + +export default new KakuyomuPlugin(); + +type Work = { + id: string; + title: string; + serialStatus: string; + tagLabels: string[]; + introduction: string; + adminCoverImageUrl?: string; + author: { + __ref: string; + }; +}; + +type UserAccount = { + activityName: string; +}; + +type Chapter = { + id: string; + title: string; +}; + +type Episode = { + title: string; + id: string; + publishedAt: string; +}; + +type TableOfContentsChapter = { + chapter: { + __ref: string; + }; + episodeUnions: { + __ref: string; + }[]; +};