import type { AniListAuthorisation, UserIdentity } from './identity'; import type { Media } from './media'; export enum SortOptions { SCORE, MINUTES_WATCHED, COUNT } export interface WrappedMediaFormat { startYears: { startYear: number; minutesWatched: number; count: number; }[]; genres: { meanScore: number; minutesWatched: number; chaptersRead: number; genre: string; mediaIds: number[]; }[]; tags: { meanScore: number; minutesWatched: number; chaptersRead: number; tag: { name: string; }; mediaIds: number[]; }[]; } export interface Wrapped { statistics: { anime: WrappedMediaFormat; manga: WrappedMediaFormat; }; activities: { statusCount: number; messageCount: number; }; avatar: { large: string; }; } const profileActivities = async ( user: AniListAuthorisation, identity: UserIdentity, date = new Date(), disableLoopingActivityCounter = false ) => { const now = date.getTime(); const get = async (page: number) => await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers: { Authorization: `${user.tokenType} ${user.accessToken}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: `{ Page(page: ${page}) { activities(userId: ${identity.id}, type_in: [ TEXT, MESSAGE ], createdAt_greater: ${Math.floor( new Date(date.getFullYear(), 0, 1).getTime() / 1000 )}, createdAt_lesser: ${Math.floor(new Date(date.getFullYear(), 7, 1).getTime() / 1000)}) { ... on TextActivity { type createdAt } ... on MessageActivity { type createdAt } } pageInfo { hasNextPage } } }` }) }) ).json(); const pages = []; let page = 1; let response = await get(page); const beginningOfYear = new Date(now).setMonth(0, 1) / 1000; pages.push(response['data']['Page']['activities']); while (response['data']['Page']['pageInfo']['hasNextPage']) { if (disableLoopingActivityCounter) break; page += 1; response = await get(page); pages.push(response['data']['Page']['activities']); } return { statusCount: pages .flat() .filter( (activity) => activity.type == 'TEXT' && activity.createdAt > beginningOfYear && activity.createdAt < now / 1000 ).length, messageCount: pages .flat() .filter( (activity) => activity.type == 'MESSAGE' && activity.createdAt > beginningOfYear && activity.createdAt < now / 1000 ).length }; }; export const wrapped = async ( anilistAuthorisation: AniListAuthorisation | undefined, identity: UserIdentity, year = new Date().getFullYear(), skipActivities = false, disableLoopingActivityCounter = false ): Promise => { const headers: { [key: string]: string } = { 'Content-Type': 'application/json', Accept: 'application/json' }; if (anilistAuthorisation) { headers['Authorization'] = `${anilistAuthorisation.tokenType} ${anilistAuthorisation.accessToken}`; } const wrappedResponse = await ( await fetch('https://graphql.anilist.co', { method: 'POST', headers, body: JSON.stringify({ query: `{ User(name: "${identity.name}") { avatar { large } statistics { anime { startYears { startYear minutesWatched count } genres(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead genre mediaIds } tags(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead tag { name } mediaIds } } manga { startYears { startYear chaptersRead count } genres(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead genre mediaIds } tags(sort: [ MEAN_SCORE_DESC ]) { meanScore minutesWatched chaptersRead tag { name } mediaIds } } } } }` }) }) ).json(); let statusCountActivities = 0; let messageCountActivities = 0; if (!skipActivities && anilistAuthorisation) { const { statusCount, messageCount } = await profileActivities( anilistAuthorisation, identity, new Date(year, 11, 31), disableLoopingActivityCounter ); statusCountActivities = statusCount; messageCountActivities = messageCount; } return { statistics: wrappedResponse['data']['User']['statistics'], activities: { statusCount: statusCountActivities, messageCount: messageCountActivities }, avatar: wrappedResponse['data']['User']['avatar'] }; }; export interface TopMedia { genres: { genre: string; averageScore: number; }[]; tags: { tag: string; averageScore: number; }[]; topGenreMedia: Media; topTagMedia: Media; } export const tops = ( media: Media[], amount: number, sortMode: SortOptions, excludedKeywords: string[] = [] ): TopMedia => { const genresMap: { [genre: string]: { totalScore: number; count: number; minutesWatched: number }; } = {}; const tagsMap: { [tag: string]: { totalScore: number; count: number; minutesWatched: number } } = {}; media.forEach((m) => { if (m.mediaListEntry && m.mediaListEntry.score) { m.genres.forEach((genre) => { if (!genresMap[genre]) genresMap[genre] = { totalScore: 0, count: 0, minutesWatched: 0 }; const score = m.mediaListEntry?.score; if (score) { genresMap[genre].totalScore += score; genresMap[genre].minutesWatched += m.duration; genresMap[genre].count++; } }); m.tags.forEach((tag) => { if (tag.rank < 50) return; if (!tagsMap[tag.name]) tagsMap[tag.name] = { totalScore: 0, count: 0, minutesWatched: 0 }; const score = m.mediaListEntry?.score; if (score) { tagsMap[tag.name].totalScore += score; tagsMap[tag.name].minutesWatched += m.duration; tagsMap[tag.name].count++; } }); } }); let genres = Object.keys(genresMap) .filter((genre) => !excludedKeywords.some((keyword) => genre.includes(keyword))) .map((genre) => ({ genre, averageScore: Math.round(genresMap[genre].totalScore / genresMap[genre].count) })); let tags = Object.keys(tagsMap) .filter((genre) => !excludedKeywords.some((keyword) => genre.includes(keyword))) .map((tag) => ({ tag, averageScore: Math.round(tagsMap[tag].totalScore / tagsMap[tag].count) })); switch (sortMode) { case SortOptions.SCORE: genres = genres.sort((a, b) => b.averageScore - a.averageScore); tags = tags.sort((a, b) => b.averageScore - a.averageScore); break; case SortOptions.MINUTES_WATCHED: genres = genres.sort( (a, b) => genresMap[b.genre].minutesWatched - genresMap[a.genre].minutesWatched ); tags = tags.sort((a, b) => tagsMap[b.tag].minutesWatched - tagsMap[a.tag].minutesWatched); break; case SortOptions.COUNT: genres = genres.sort((a, b) => genresMap[b.genre].count - genresMap[a.genre].count); tags = tags.sort((a, b) => tagsMap[b.tag].count - tagsMap[a.tag].count); break; } genres = genres.slice(0, amount); tags = tags.slice(0, amount); let topGenreMedia; try { topGenreMedia = media.find((m) => m.genres.includes(genres[0].genre)) || media[0]; } catch { topGenreMedia = media[0]; } let topTagMedia; try { topTagMedia = media.find((m) => m.tags.some((tag) => tag.name === tags[0].tag)) || media[0]; } catch { topTagMedia = media[0]; } return { genres, tags, topGenreMedia, topTagMedia }; };