rsshub
Version:
Make RSS Great Again!
190 lines (166 loc) • 6.64 kB
text/typescript
import { Route, Data, DataItem } from '@/types';
import { art } from '@/utils/render';
import path from 'node:path';
import { Context } from 'hono';
import { SearchBuilderR18, SearchParams, NarouNovelFetch } from 'narou';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import { RankingPeriod, periodToJapanese, novelTypeToJapanese, periodToOrder, NovelType, SyosetuSub, syosetuSubToJapanese, syosetuSubToNocgenre } from './types/ranking-r18';
/**
* Implementation of "Syosetu" R18 Rankings
*
* While "Syosetu" only provides ranking API for "Syosetu o yomou" (general audience),
* equivalent ranking functionality can be achieved using the point-based sorting in the search API.
*
* This implementation utilizes the 'order' parameter (e.g., dailypoint, weeklypoint)
* of the search API to replicate ranking functionality across all Syosetu subsidiary sites.
*/
const getParameters = () => {
// Generate options for sub parameter
const subOptions = Object.entries(SyosetuSub).map(([, value]) => ({
value,
label: syosetuSubToJapanese[value],
}));
// 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})`,
}));
return {
sub: {
description: 'Target site for R18 rankings',
options: subOptions,
},
type: {
description: 'Detailed ranking type (format: period_noveltype)',
options: periodOptions.flatMap((period) =>
novelTypeOptions.map((type) => ({
value: `${period.value}_${type.value}`,
label: `${period.label} ${type.label}`,
}))
),
},
};
};
const getBest5RadarItems = () =>
Object.entries(SyosetuSub).flatMap(([, domain]) =>
Object.values(RankingPeriod).map((period) => ({
title: `${syosetuSubToJapanese[domain]} ${periodToJapanese[period]}ランキング BEST5`,
source: [`${domain === SyosetuSub.MOONLIGHT_BL ? SyosetuSub.MOONLIGHT : domain}.syosetu.com/rank/${domain === SyosetuSub.MOONLIGHT_BL ? 'bltop' : 'top'}`],
target: `/rankingr18/${domain}/${period}_${NovelType.TOTAL}?limit=5`,
}))
);
export const route: Route = {
path: '/rankingr18/:sub/:type',
categories: ['reading'],
example: '/syosetu/rankingr18/noc/daily_total?limit=50',
parameters: getParameters(),
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
name: 'R18 Rankings',
url: 'syosetu.com/site/group',
maintainers: ['SnowAgar25'],
handler,
description: `
| Period | Description | 説明 |
| --- | --- | --- |
| daily | Daily Ranking | 日間ランキング |
| weekly | Weekly Ranking | 週間ランキング |
| monthly | Monthly Ranking | 月間ランキング |
| quarter | Quarterly Ranking | 四半期ランキング |
| yearly | Yearly Ranking | 年間ランキング |
| Novel Type | Description | 説明 |
| --- | --- | --- |
| total | All Works | 総合 |
| t | Short Stories | 短編 |
| r | Ongoing Series | 連載中 |
| er | Completed Series | 完結済 |
::: tip
Combine Period and Novel Type with \`_\`.
For example: \`daily_total\`, \`weekly_r\`, \`monthly_er\`
:::`,
radar: [
{
source: ['noc.syosetu.com/rank/list/type/:type'],
target: '/rankingr18/noc/:type',
},
{
source: ['mid.syosetu.com/rank/list/type/:type'],
target: '/rankingr18/mid/:type',
},
{
source: ['mnlt.syosetu.com/rank/list/type/:type'],
target: '/rankingr18/mnlt/:type',
},
{
source: ['mnlt.syosetu.com/rank/bllist/type/:type'],
target: '/rankingr18/mnlt-bl/:type',
},
...getBest5RadarItems(),
],
};
function parseRankingType(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 ranking type: ${type}`);
}
return {
period: periodStr as RankingPeriod,
novelType: novelTypeStr as NovelType,
};
}
function getRankingTitle(type: string, limit: number): string {
const { period, novelType } = parseRankingType(type);
return `${periodToJapanese[period]}${novelTypeToJapanese[novelType]}ランキング BEST${limit}`;
}
async function handler(ctx: Context): Promise<Data> {
const { sub, type } = ctx.req.param();
const baseUrl = `https://${sub === SyosetuSub.MOONLIGHT_BL ? SyosetuSub.MOONLIGHT : sub}.syosetu.com`;
const rankingUrl = `${baseUrl}/rank/list/type/${type}`;
const api = new NarouNovelFetch();
const limit = Math.min(Number(ctx.req.query('limit') ?? 300), 300);
const { period, novelType } = parseRankingType(type);
const searchParams: SearchParams = {
gzip: 5,
lim: limit,
order: periodToOrder[period],
};
// TOTAL: Skip type filter to get all types combined
if (novelType !== NovelType.TOTAL) {
searchParams.type = novelType;
}
if (!(sub in syosetuSubToNocgenre)) {
throw new InvalidParameterError(`Invalid subsite: ${sub}`);
}
const nocgenre = syosetuSubToNocgenre[sub];
const builder = new SearchBuilderR18(searchParams, api).r18Site(nocgenre);
const result = await builder.execute();
const items = result.values.map((novel, index) => ({
title: `#${index + 1} ${novel.title}`,
link: `https://novel18.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: `小説家になろう (${sub}) - ${getRankingTitle(type, limit)}`,
link: rankingUrl,
item: items as DataItem[],
language: 'ja',
};
}