UNPKG

rsshub

Version:
312 lines (268 loc) • 11.5 kB
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, }; }