UNPKG

rsshub

Version:
283 lines (240 loc) 11.1 kB
import { Route, Data, DataItem } from '@/types'; import { art } from '@/utils/render'; import path from 'node:path'; import { Context } from 'hono'; import { Genre, SearchBuilder, SearchParams, NarouNovelFetch, GenreNotation } from 'narou'; import InvalidParameterError from '@/errors/types/invalid-parameter'; import { handleIsekaiRanking } from './ranking-isekai'; import { RankingPeriod, periodToJapanese, novelTypeToJapanese, periodToOrder, RankingType, NovelType, isekaiCategoryToJapanese, IsekaiCategory } from './types/ranking'; const getParameters = () => { // Generate ranking type options const rankingTypeOptions = [ { value: RankingType.LIST, label: '総合ランキング (General Ranking)' }, { value: RankingType.GENRE, label: 'ジャンル別ランキング (Genre Ranking)' }, { value: RankingType.ISEKAI, label: '異世界転生/転移ランキング (Isekai Ranking)' }, ]; // Generate period options const periodOptions = Object.entries(RankingPeriod).map(([key, value]) => ({ value, label: `${periodToJapanese[value]} (${key})`, })); // Generate novel type options const novelTypeOptions = Object.entries(NovelType).map(([key, value]) => ({ value, label: `${novelTypeToJapanese[value]} (${key})`, })); // Generate genre options const genreOptions = Object.entries(Genre) .filter(([, value]) => typeof value === 'number') // Filter out reverse mappings .map(([key, value]) => ({ value: value.toString(), label: key, })); // Generate isekai category options const isekaiOptions = Object.entries(IsekaiCategory).map(([key, value]) => ({ value, label: `${isekaiCategoryToJapanese[value]} (${key})`, })); return { listType: { description: 'Ranking type', options: rankingTypeOptions, }, type: { description: 'Detailed ranking type, can be found in Syosetu ranking URLs', options: [ // General ranking options ...periodOptions.flatMap((period) => novelTypeOptions.map((novelType) => ({ value: `${period.value}_${novelType.value}`, label: `${RankingType.LIST} - [${periodToJapanese[period.value]}] 総合ランキング - ${novelTypeToJapanese[novelType.value]}`, })) ), // Genre ranking options ...periodOptions.flatMap((period) => genreOptions.flatMap((genre) => novelTypeOptions.map((novelType) => ({ value: `${period.value}_${genre.value}_${novelType.value}`, label: `${RankingType.GENRE} - [${periodToJapanese[period.value]}] ${GenreNotation[genre.value]}ランキング - ${novelTypeToJapanese[novelType.value]}`, })) ) ), // Isekai ranking options ...periodOptions.flatMap((period) => isekaiOptions.flatMap((category) => novelTypeOptions.map((novelType) => ({ value: `${period.value}_${category.value}_${novelType.value}`, label: `${RankingType.ISEKAI} - [${periodToJapanese[period.value]}] 異世界転生/転移${isekaiCategoryToJapanese[category.value]}ランキング - ${novelTypeToJapanese[novelType.value]}`, })) ) ), ], }, }; }; const getBest5RadarItems = () => { // List const periodRankings = Object.values(RankingPeriod).map((period) => ({ title: `${periodToJapanese[period]}ランキング BEST5`, source: ['yomou.syosetu.com/rank/top/'], target: `/ranking/list/${period}_total?limit=5`, })); // Genre const genreRankings = Object.entries(Genre) .filter(([, value]) => typeof value === 'number' && value !== Genre.SonotaReplay && value !== Genre.NonGenre) .map(([, value]) => ({ title: `[${periodToJapanese.daily}] ${GenreNotation[value]}ランキング BEST5`, source: ['yomou.syosetu.com/rank/top/'], target: `/ranking/genre/daily_${value}_total?limit=5`, })); // Isekai const isekaiRankings = Object.values(IsekaiCategory).map((category) => ({ title: `[${periodToJapanese.daily}] 異世界転生/転移${isekaiCategoryToJapanese[category]}ランキング BEST5`, source: ['yomou.syosetu.com/rank/top/'], target: `/ranking/isekai/daily_${category}_total?limit=5`, })); return [...periodRankings, ...genreRankings, ...isekaiRankings]; }; export const route: Route = { path: '/ranking/:listType/:type', categories: ['reading'], example: '/syosetu/ranking/list/daily_total?limit=50', parameters: getParameters(), features: { requireConfig: false, requirePuppeteer: false, antiCrawler: false, supportBT: false, supportPodcast: false, supportScihub: false, }, name: 'Rankings', url: 'yomou.syosetu.com/rank/top', maintainers: ['SnowAgar25'], handler, description: ` | Keyword | Description | 説明 | | --- | --- | --- | | list | Overall Ranking | 総合ランキング | | genre | Genre Ranking | ジャンル別ランキング | | isekai | Isekai/Reincarnation/Transfer Ranking | 異世界転生/転移ランキング | | Period | Description | | --- | --- | | daily | Daily Ranking | | weekly | Weekly Ranking | | monthly | Monthly Ranking | | quarter | Quarterly Ranking | | yearly | Yearly Ranking | | Type | Description | | --- | --- | | total | All Works | | t | Short Stories | | r | Ongoing Series | | er | Completed Series | ::: warning Please note that novel type options may vary depending on the ranking category. ランキングの種類によって、小説タイプが異なる場合がございますのでご注意ください。 ::: ::: danger 注意事項 The "注目度ランキング" (Attention Ranking) is not supported as syosetu does not provide a public API for this feature and the results cannot be replicated through the search API. 「注目度ランキング」については、API が非公開で検索 API でも同様の結果を得ることができないため、本 Route ではサポートしておりません。 ::: ::: tip 異世界転生/転移ランキングについて (Isekai) When multiple works have the same points, their order may differ from syosetu's ranking as syosetu randomizes the order for works with identical points. 集計の結果、同じポイントの作品が複数存在する場合、Syosetu ではランダムで順位が決定されるため、本 Route の順位と異なる場合があります。 ::: `, radar: [ { source: ['yomou.syosetu.com/rank/list/type/:type'], target: '/ranking/list/:type', }, { source: ['yomou.syosetu.com/rank/genrelist/type/:type'], target: '/ranking/genre/:type', }, { source: ['yomou.syosetu.com/rank/isekailist/type/:type'], target: '/ranking/isekai/:type', }, ...getBest5RadarItems(), ], }; function parseGeneralRankingType(type: string): { period: RankingPeriod; novelType: NovelType } { const [periodStr, novelTypeStr] = type.split('_'); const period = periodStr as RankingPeriod; const novelType = novelTypeStr as NovelType; const isValid = [Object.values(RankingPeriod).includes(period), Object.values(NovelType).includes(novelType)].every(Boolean); if (!isValid) { throw new InvalidParameterError(`Invalid general ranking type: ${type}`); } return { period, novelType }; } function parseGenreRankingType(type: string): { period: RankingPeriod; genre: number; novelType: NovelType } { const [periodStr, genreStr, novelTypeStr = NovelType.TOTAL] = type.split('_'); const period = periodStr as RankingPeriod; const genre = Number(genreStr) as Genre; const novelType = novelTypeStr as NovelType; const isValid = [Object.values(RankingPeriod).includes(period), Object.values(Genre).includes(genre), Object.values(NovelType).includes(novelType), genre !== Genre.SonotaReplay, genre !== Genre.NonGenre].every(Boolean); if (!isValid) { throw new InvalidParameterError(`Invalid genre ranking type: ${type}`); } return { period, genre, novelType }; } async function handler(ctx: Context): Promise<Data> { const { listType, type } = ctx.req.param(); const rankingType = listType as RankingType; const limit = Math.min(Number(ctx.req.query('limit') ?? 300), 300); const api = new NarouNovelFetch(); const searchParams: SearchParams = { gzip: 5, lim: limit, }; let rankingUrl: string; let rankingTitle: string; // Build search parameters and titles based on ranking type switch (rankingType) { case RankingType.LIST: { const { period, novelType } = parseGeneralRankingType(type); rankingUrl = `https://yomou.syosetu.com/rank/list/type/${type}`; rankingTitle = `[${periodToJapanese[period]}] 総合ランキング - ${novelTypeToJapanese[novelType]} BEST${limit}`; searchParams.order = periodToOrder[period]; if (novelType !== NovelType.TOTAL) { searchParams.type = novelType; } break; } case RankingType.GENRE: { const { period, genre, novelType } = parseGenreRankingType(type); rankingUrl = `https://yomou.syosetu.com/rank/genrelist/type/${type}`; rankingTitle = `[${periodToJapanese[period]}] ${GenreNotation[genre]}ランキング - ${novelTypeToJapanese[novelType]} BEST${limit}`; searchParams.order = periodToOrder[period]; searchParams.genre = genre as Genre; if (novelType !== NovelType.TOTAL) { searchParams.type = novelType; } break; } case RankingType.ISEKAI: return handleIsekaiRanking(type, limit); default: throw new InvalidParameterError(`Invalid ranking type: ${type}`); } const builder = new SearchBuilder(searchParams, api); const result = await builder.execute(); const items = result.values.map((novel, index) => ({ title: `#${index + 1} ${novel.title}`, link: `https://ncode.syosetu.com/${String(novel.ncode).toLowerCase()}`, description: art(path.join(__dirname, 'templates/description.art'), { novel, }), author: novel.writer, category: novel.keyword.split(/[\s/\uFF0F]/).filter(Boolean), })); return { title: `小説家になろう - ${rankingTitle}`, link: rankingUrl, item: items as DataItem[], language: 'ja', }; }