use anilist instead of mal api

This commit is contained in:
Aykhan 2025-08-11 23:03:02 +04:00
parent 3a33b0ee78
commit 7f4a6ad5fc
6 changed files with 120 additions and 102 deletions

View file

@ -23,7 +23,7 @@ export function makeAnimetsuEmbed(id: string, rank: number = 100) {
const serverName = id as (typeof ANIMETSU_SERVERS)[number];
const query = JSON.parse(ctx.url);
const { type, malId, episode } = query;
const { type, anilistId, episode } = query;
if (type !== 'movie' && type !== 'show') {
throw new NotFoundError('Unsupported media type');
@ -34,7 +34,7 @@ export function makeAnimetsuEmbed(id: string, rank: number = 100) {
headers,
query: {
server: serverName,
id: String(malId),
id: String(anilistId),
num: String(episode ?? 1),
subType: 'dub',
},

View file

@ -21,13 +21,13 @@ export function makeZunimeEmbed(id: string, rank: number = 100) {
const serverName = id as (typeof ZUNIME_SERVERS)[number];
const query = JSON.parse(ctx.url);
const { malId, episode } = query;
const { anilistId, episode } = query;
const res = await ctx.proxiedFetcher(`${'/sources'}`, {
baseUrl,
headers,
query: {
id: String(malId),
id: String(anilistId),
ep: String(episode ?? 1),
host: serverName,
type: 'dub',

View file

@ -1,16 +1,16 @@
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { getAnilistIdFromMedia } from '@/utils/anilist';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { getMalIdFromMedia } from '@/utils/mal';
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const malId = await getMalIdFromMedia(ctx, ctx.media);
const anilistId = await getAnilistIdFromMedia(ctx, ctx.media);
const query: any = {
type: ctx.media.type,
title: ctx.media.title,
tmdbId: ctx.media.tmdbId,
imdbId: ctx.media.imdbId,
malId,
anilistId,
...(ctx.media.type === 'show' && {
season: ctx.media.season.number,
episode: ctx.media.episode.number,

View file

@ -1,16 +1,16 @@
import { SourcererOutput, makeSourcerer } from '@/providers/base';
import { getAnilistIdFromMedia } from '@/utils/anilist';
import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context';
import { getMalIdFromMedia } from '@/utils/mal';
async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise<SourcererOutput> {
const malId = await getMalIdFromMedia(ctx, ctx.media);
const anilistId = await getAnilistIdFromMedia(ctx, ctx.media);
const query: any = {
type: ctx.media.type,
title: ctx.media.title,
tmdbId: ctx.media.tmdbId,
imdbId: ctx.media.imdbId,
malId,
anilistId,
...(ctx.media.type === 'show' && {
season: ctx.media.season.number,
episode: ctx.media.episode.number,

110
src/utils/anilist.ts Normal file
View file

@ -0,0 +1,110 @@
import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media';
import { ScrapeContext } from '@/utils/context';
type AnilistMedia = {
id: number;
type: 'ANIME' | 'MANGA';
format: 'TV' | 'TV_SHORT' | 'MOVIE' | 'SPECIAL' | 'OVA' | 'ONA' | 'MUSIC' | 'MANGA' | 'NOVEL' | 'ONE_SHOT';
seasonYear?: number;
title: {
romaji: string;
english?: string;
native?: string;
};
};
type AnilistSearchResponse = {
data: {
Page: {
media: AnilistMedia[];
};
};
};
const cache = new Map<string, number>();
function normalizeTitle(t: string) {
return t
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function matchesType(mediaType: 'show' | 'movie', anilist: AnilistMedia) {
if (mediaType === 'show') {
return ['TV', 'TV_SHORT', 'OVA', 'ONA', 'SPECIAL'].includes(anilist.format);
}
return anilist.format === 'MOVIE';
}
const anilistQuery = `
query ($search: String, $type: MediaType) {
Page(page: 1, perPage: 20) {
media(search: $search, type: $type, sort: POPULARITY_DESC) {
id
type
format
seasonYear
title {
romaji
english
native
}
}
}
}
`;
export async function getAnilistIdFromMedia(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise<number> {
const key = `${media.type}:${media.title}:${media.releaseYear}`;
const cached = cache.get(key);
if (cached) return cached;
const res = await ctx.proxiedFetcher<AnilistSearchResponse>('', {
baseUrl: 'https://graphql.anilist.co',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
query: anilistQuery,
variables: {
search: media.title,
type: 'ANIME',
},
}),
});
const items = res.data?.Page?.media ?? [];
if (!items.length) {
throw new Error('AniList id not found');
}
const targetTitle = normalizeTitle(media.title);
const scored = items
.filter((it) => matchesType(media.type, it))
.map((it) => {
const titles: string[] = [it.title.romaji];
if (it.title.english) titles.push(it.title.english);
if (it.title.native) titles.push(it.title.native);
const normTitles = titles.map(normalizeTitle).filter(Boolean);
const exact = normTitles.includes(targetTitle);
const partial = normTitles.some((t) => t.includes(targetTitle) || targetTitle.includes(t));
const yearDelta = it.seasonYear ? Math.abs(it.seasonYear - media.releaseYear) : 5;
let score = 0;
if (exact) score += 100;
else if (partial) score += 50;
score += Math.max(0, 20 - yearDelta * 4);
return { it, score };
})
.sort((a, b) => b.score - a.score);
const winner = scored[0]?.it ?? items[0];
const anilistId = winner?.id;
if (!anilistId) throw new Error('AniList id not found');
cache.set(key, anilistId);
return anilistId;
}

View file

@ -1,92 +0,0 @@
import { MovieMedia, ShowMedia } from '@/entrypoint/utils/media';
import { ScrapeContext } from '@/utils/context';
type JikanAnime = {
mal_id: number;
type?: string;
title?: string;
title_english?: string | null;
titles?: Array<{ title: string; type: string }>;
year?: number | null;
aired?: {
prop?: { from?: { year?: number | null } };
};
};
type JikanSearchResponse = {
data: JikanAnime[];
};
const cache = new Map<string, number>();
function normalizeTitle(t: string) {
return t
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function guessYear(a: JikanAnime): number | null {
return (typeof a.year === 'number' ? a.year : null) ?? a.aired?.prop?.from?.year ?? null;
}
function matchesType(mediaType: 'show' | 'movie', t?: string) {
if (!t) return true; // be lenient
const jt = t.toLowerCase();
return mediaType === 'show' ? jt === 'tv' : jt === 'movie';
}
export async function getMalIdFromMedia(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise<number> {
const key = `${media.type}:${media.title}:${media.releaseYear}`;
const cached = cache.get(key);
if (cached) return cached;
const q = media.title;
// Jikan search
const res = await ctx.proxiedFetcher<JikanSearchResponse>('/anime', {
baseUrl: 'https://api.jikan.moe/v4',
query: {
q,
// Jikan expects tv|movie etc; provide a hint but still filter manually
type: media.type === 'show' ? 'tv' : 'movie',
sfw: 'true',
limit: '20',
order_by: 'popularity',
sort: 'asc',
},
});
const items = Array.isArray(res?.data) ? res.data : [];
if (!items.length) {
throw new Error('MAL id not found');
}
const targetTitle = normalizeTitle(media.title);
// Score results by title similarity and year closeness
const scored = items
.filter((it) => matchesType(media.type, it.type))
.map((it) => {
const titles: string[] = [it.title || ''];
if (it.title_english) titles.push(it.title_english);
if (Array.isArray(it.titles)) titles.push(...it.titles.map((t) => t.title));
const normTitles = titles.map(normalizeTitle).filter(Boolean);
const exact = normTitles.includes(targetTitle);
const partial = normTitles.some((t) => t.includes(targetTitle) || targetTitle.includes(t));
const y = guessYear(it);
const yearDelta = typeof y === 'number' ? Math.abs(y - media.releaseYear) : 5; // unknown year => penalize
let score = 0;
if (exact) score += 100;
else if (partial) score += 50;
score += Math.max(0, 20 - yearDelta * 4);
return { it, score };
})
.sort((a, b) => b.score - a.score);
const winner = scored[0]?.it ?? items[0];
const malId = winner?.mal_id;
if (!malId) throw new Error('MAL id not found');
cache.set(key, malId);
return malId;
}