rsshub
Version:
Make RSS Great Again!
312 lines (268 loc) • 11.5 kB
text/typescript
import { Data, DataItem, Route } from '@/types';
import path from 'node:path';
import got from '@/utils/got';
import { load } from 'cheerio';
import { parseDate } from '@/utils/parse-date';
import cache from '@/utils/cache';
import { config } from '@/config';
import { art } from '@/utils/render';
const actualParametersDescTable = `
| Name | Default | Description |
| ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| \`includeMode\` | All mode | Could be \`osu\`, \`mania\`, \`fruits\` or \`taiko\`. Specify included game mode of beatmaps. Including this paramseter multiple times to specify multiple game modes, e.g.: \`includeMode=osu&includeMode=mania\`. Subscribe to all game modes if not specified |
| \`difficultyLimit\` | None | Lower/upper limit of star rating of the beatmaps in the beatmapset item, e.g.:\`difficultyLimit=U6\`. Checkout tips in descriptions for detailed explaination and examples. |
| \`modeInTitle\` | \`true\` | \`true\` or \`false\` Add mode info into feed title.
`;
const descriptionDoc: string = `
Subscribe to the new beatmaps on https://osu.ppy.sh/beatmapsets.
#### Parameter Description
Parameters allows you to:
- Filter game mode
- Limit beatmap difficulty
- Show/hide game mode in feed title
Below is a table of all allowed parameters passed to \`routeParams\`
${actualParametersDescTable}
This actual parameters should be passed as \`routeParams\` in URL Query String format without \`?\`, e.g.:
/osu/latest-ranked/modeInTitle=true&includeMode=osu
:::tip
You could make use of \`difficultyLimit\` paramters to create a "high difficulty/low difficulty only" only feed.
For example, if you only wants to play low star rating beatmap like 1 or 2 star, you could subscribe to:
/osu/latest-ranked/difficultyLimit=U2
This will filter out all beatmapsets that do not provide at least one beatmap with star rating<=\`2.00\`.
Similarly, you could use lower bound to filter out beatmapsets which don't have at least one beatmap
with star rating higher than a certain threshold.
/osu/latest-ranked/difficultyLimit=L6
Now all beatmapsets that don't provided at least one beatmap with star rating higher than \`6.00\` will be filtered.
:::
`;
export const route: Route = {
path: '/latest-ranked/:routeParams?',
categories: ['game'],
example: '/osu/latest-ranked/includeMode=osu&difficultyLimit=L3&difficultyLimit=U7',
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
supportRadar: true,
},
parameters: {
routeParams: {
description: 'Used to pass route parameters in Query String format. Check out route description for more info.',
default: 'null',
},
},
name: 'Latest Ranked Beatmap',
description: descriptionDoc,
maintainers: ['nfnfgo'],
radar: [
{
source: ['osu.ppy.sh/beatmapsets'],
},
],
handler,
};
interface Beatmap {
beatmapset_id: number;
difficulty_rating: number;
id: number;
mode: string;
status: string;
total_length: number;
user_id: number;
version: string;
accuracy: number;
ar: number;
bpm: number;
convert: boolean;
count_circles: number;
count_sliders: number;
count_spinners: number;
cs: number;
deleted_at: string | null;
drain: number;
hit_length: number;
is_scoreable: boolean;
last_updated: string;
mode_int: number;
passcount: number;
playcount: number;
ranked: number;
url: string;
checksum: string;
max_combo: number;
}
interface NominationsSummary {
current: number;
eligible_main_rulesets: string[];
required_meta: {
main_ruleset: number;
non_main_ruleset: number;
};
}
interface Covers {
cover: string;
'cover@2x': string;
card: string;
'card@2x': string;
list: string;
'list@2x': string;
slimcover: string;
'slimcover@2x': string;
}
interface Availability {
download_disabled: boolean;
more_information: string | null;
}
interface BeatmapsetInfo {
artist: string;
artist_unicode: string;
covers: Covers;
creator: string;
favourite_count: number;
hype: null | string;
id: number;
nsfw: boolean;
offset: number;
play_count: number;
preview_url: string;
source: string;
spotlight: boolean;
status: string;
title: string;
title_unicode: string;
track_id: number;
user_id: number;
video: boolean;
bpm: number;
can_be_hyped: boolean;
deleted_at: string | null;
discussion_enabled: boolean;
discussion_locked: boolean;
is_scoreable: boolean;
last_updated: string;
legacy_thread_url: string;
nominations_summary: NominationsSummary;
ranked: number;
ranked_date: string;
storyboard: boolean;
submitted_date: string;
tags: string;
availability: Availability;
beatmaps: Beatmap[];
pack_tags: string[];
}
async function handler(ctx): Promise<Data> {
// Parse & retrive searchParams
const pathParams = ctx.req.param('routeParams');
// Here user actually pass the query using path param, like: `/osu/latest-ranked/includeMode=osu`
// We first retrieve path param part: `includeMode=osu`, then concat it with host to construct a "fake" URL:
// `https://osu.ppy.sh?includeMode=osu`
// Then we use URL.searchParams to parse and retrieve params from this "fake" URL.
const searchParams = new URL(`https://osu.ppy.sh?${pathParams}`).searchParams; // use URL to parse params
const includeModes = searchParams.getAll('includeMode');
const difficultyLimits = searchParams.getAll('difficultyLimit');
const modeInTitle = searchParams.get('modeInTitle') ?? 'true'; // show mode name in title, default to true.
// fetch beatmap JSON info from website within cache
let beatmapsetList = (await cache.tryGet(
'https://osu.ppy.sh/beatmapsets:JSON',
async () => {
const link = 'https://osu.ppy.sh/beatmapsets';
const response = await got.get(link);
const $ = load(response.data);
const beatmapInfo = JSON.parse($('#json-beatmaps').text() ?? '{"beatmapsets": undefined}');
const beatmapList: BeatmapsetInfo[] = beatmapInfo.beatmapsets;
// Failed to fetch, raise error
if (beatmapList === undefined) {
throw new Error('Failed to retrieve JSON beatmap info from osu! website');
}
return beatmapList;
},
config.cache.routeExpire,
false
)) as BeatmapsetInfo[];
// Sort beatmap by difficultyRate.desc
// This step is necessary even if difficultyLimit not enabled, since we want the beatmap
// in RSS description sorted when displayed
for (const item of beatmapsetList) {
item.beatmaps.sort((a, b) => a.difficulty_rating - b.difficulty_rating);
}
// filter beatmapset types
// Note:
// One Osu beatmapset could actually contains several beatmaps with different game mode.
// Here for simplicity we just use the mode of first beatmap in this set for filtering criteria.
if (includeModes?.length && includeModes?.length > 0) {
beatmapsetList = beatmapsetList.filter((bm) => includeModes.includes(bm.beatmaps[0].mode));
}
let upperLimit = 99; // Osu! will never have maps with 99+ star rating right?
let lowerLimit = 0;
if (difficultyLimits && difficultyLimits.length > 0 && difficultyLimits.length < 2) {
for (const dfLimit of difficultyLimits) {
if (dfLimit.startsWith('U')) {
upperLimit = Number.parseFloat(dfLimit.slice(1));
} else if (dfLimit.startsWith('L')) {
lowerLimit = Number.parseFloat(dfLimit.slice(1));
}
}
const difficultyRateFilterFunc = (item: BeatmapsetInfo): boolean => {
if (item.beatmaps.at(0)!.difficulty_rating > upperLimit) {
return false;
}
if (item.beatmaps.at(-1)!.difficulty_rating < lowerLimit) {
return false;
}
return true;
};
beatmapsetList = beatmapsetList.filter((item) => difficultyRateFilterFunc(item));
}
// Returns a user-readble string that shows details about route parameter config
function getReadableFeedConfig(): string {
if (!pathParams) {
return '';
}
let readableConf = 'Feed Configurations:\n';
readableConf += `Game Mode: ${includeModes.length > 0 ? JSON.stringify(includeModes) : 'All modes'}\n`;
readableConf += `Star Rating Limit: Lower=${lowerLimit}, Upper=${upperLimit}`;
return readableConf;
}
// Construct beatmap feed items
const rssItems: DataItem[] = beatmapsetList.map((beatmapset) => {
// Format publication date using parseDate utility
// Here it make sense to consider the ranked date as the pubDate of this item since this is ranked map RSS
const pubDate = parseDate(beatmapset.ranked_date);
// Select the best resolution cover (2x if available)
const coverImage = beatmapset.covers['cover@2x'] || beatmapset.covers.cover;
const bannerImage = beatmapset.covers['card@2x'] || beatmapset.covers.card;
// Readable beatmap total length
const readableTotalLength = `${Math.floor(beatmapset.beatmaps[0].total_length / 60)
.toString()
.padStart(2, '0')}:${(beatmapset.beatmaps[0].total_length % 60).toString().padStart(2, '0')}`;
const modeLiteralToDisplayNameMap = {
osu: 'Osu!',
fruits: 'Osu!Catch',
taiko: 'Osu!Taiko',
mania: 'Osu!Mania',
};
// Create a description with beatmap details and a table of difficulties
const description = art(path.join(__dirname, 'templates/beatmapset.art'), { ...beatmapset, readableTotalLength, modeLiteralToDisplayNameMap });
return {
title: `${modeInTitle === 'true' ? `[${modeLiteralToDisplayNameMap[beatmapset.beatmaps[0].mode]}] ` : ``}${beatmapset.title_unicode ?? beatmapset.title}`,
description,
pubDate,
link: `https://osu.ppy.sh/beatmapsets/${beatmapset.id}`,
category: ['osu!', 'game'],
author: [{ name: beatmapset.creator }],
image: coverImage,
banner: bannerImage,
updated: beatmapset.last_updated,
};
});
return {
title: 'Osu! Latest Ranked Map',
link: 'https://osu.ppy.sh/beatmapsets',
description: `Newly ranked beatmaps at https://osu.ppy.sh/beatmapsets.\n${getReadableFeedConfig()}`,
item: rssItems,
};
}