mirror of
https://github.com/p-stream/providers.git
synced 2026-03-11 17:55:36 +00:00
use anilist instead of mal api
This commit is contained in:
parent
3a33b0ee78
commit
7f4a6ad5fc
6 changed files with 120 additions and 102 deletions
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
110
src/utils/anilist.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue