@serifpersia/dango 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/client/dist/assets/AnimeInfo-CMxs0nRH.css +1 -0
- package/client/dist/assets/AnimeInfo-DVdaocCl.js +1 -0
- package/client/dist/assets/AnimeInfoPage-Bx5EvGt-.js +2 -0
- package/client/dist/assets/Asmr-C2i9GKD4.css +1 -0
- package/client/dist/assets/Asmr-CIjmFtRH.js +2 -0
- package/client/dist/assets/Asmr.module-DPG2JhDJ.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-CIYcyon5.css +1 -0
- package/client/dist/assets/AvSyncCalibrator-vqErH7Cm.js +10 -0
- package/client/dist/assets/ErrorMessage-BwSjzblw.js +1 -0
- package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
- package/client/dist/assets/Home-D7YfGBUk.js +1 -0
- package/client/dist/assets/Home-DKCfEiwl.css +1 -0
- package/client/dist/assets/Insights-BljI64Ua.css +1 -0
- package/client/dist/assets/Insights-CLQGrlpX.js +1 -0
- package/client/dist/assets/Manga-DQ9HYnpk.js +1 -0
- package/client/dist/assets/Manga-H8Wb04w3.css +1 -0
- package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
- package/client/dist/assets/Map-L_ryVBTq.js +1 -0
- package/client/dist/assets/Mature-BeWzAJ7B.js +1 -0
- package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
- package/client/dist/assets/Player-CYpd__FP.js +30 -0
- package/client/dist/assets/Player-DreAhybc.css +1 -0
- package/client/dist/assets/PlayerRedirect-fxUAp9JV.js +1 -0
- package/client/dist/assets/PlayerSettings-BcsotxP9.js +1 -0
- package/client/dist/assets/PlayerSettings-Cqua1CDD.css +1 -0
- package/client/dist/assets/QueueRail-B9Pgbqc3.js +1 -0
- package/client/dist/assets/QueueRail-BnaltOP8.css +1 -0
- package/client/dist/assets/Radio-BvdY5twD.js +1 -0
- package/client/dist/assets/Radio-CUuYy3DY.css +1 -0
- package/client/dist/assets/Search-BzO-aRP7.css +1 -0
- package/client/dist/assets/Search-R9YLAXS1.js +1 -0
- package/client/dist/assets/Search.module-D8ZbQAiK.js +1 -0
- package/client/dist/assets/Settings-9kYcXdTR.js +21 -0
- package/client/dist/assets/Settings-DF8JThUL.css +1 -0
- package/client/dist/assets/SkeletonGrid-D5vRvX5q.js +1 -0
- package/client/dist/assets/SkeletonGrid-DEbYtO4H.css +1 -0
- package/client/dist/assets/SynopsisText-BlFw0R-0.js +1 -0
- package/client/dist/assets/SynopsisText-CUgOjOxt.css +1 -0
- package/client/dist/assets/Trackers-DfZlnnVq.js +2 -0
- package/client/dist/assets/Trackers-Dt9lR_uP.css +1 -0
- package/client/dist/assets/Tv-CVKC2YBs.css +1 -0
- package/client/dist/assets/Tv-Db1Wx_b7.js +15 -0
- package/client/dist/assets/Watchlist-B2nkNZqM.css +1 -0
- package/client/dist/assets/Watchlist-a77CYtzu.js +1 -0
- package/client/dist/assets/hls-6Owgw2Aj.js +1 -0
- package/client/dist/assets/hls-BOLXTnjL.js +42 -0
- package/client/dist/assets/index-B6qdXFtl.css +1 -0
- package/client/dist/assets/index-B8TPVaIS.js +30354 -0
- package/client/dist/assets/index-CpjgU8ou.js +178 -0
- package/client/dist/assets/useAnimeInfoData-BOfyi9UJ.js +1 -0
- package/client/dist/assets/useIsMobile-CM15uQn8.css +1 -0
- package/client/dist/assets/useIsMobile-Cb8HcKRj.js +1 -0
- package/client/dist/assets/useMatureConsent-CZMYBVXd.js +1 -0
- package/client/dist/assets/vendor-rLNfZm7Y.js +1 -0
- package/client/dist/av-sync-test.mp4 +0 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/index.html +31 -0
- package/client/dist/logo-mochi.png +0 -0
- package/client/dist/logo-sakura.png +0 -0
- package/client/dist/logo.png +0 -0
- package/client/dist/placeholder.svg +4 -0
- package/client/dist/robots.txt +3 -0
- package/orchestrator.js +416 -0
- package/package.json +89 -0
- package/server/dist/app-auth.js +231 -0
- package/server/dist/config.js +133 -0
- package/server/dist/controllers/auth.controller.js +247 -0
- package/server/dist/controllers/data.controller.js +785 -0
- package/server/dist/controllers/insights.controller.js +197 -0
- package/server/dist/controllers/proxy.controller.js +665 -0
- package/server/dist/controllers/settings.controller.js +267 -0
- package/server/dist/controllers/watchlist.controller.js +967 -0
- package/server/dist/db.js +152 -0
- package/server/dist/discord-gateway.js +381 -0
- package/server/dist/discord-rpc.js +424 -0
- package/server/dist/github-sync.js +403 -0
- package/server/dist/google.js +549 -0
- package/server/dist/lib/anilist.js +1519 -0
- package/server/dist/lib/anischedule.js +202 -0
- package/server/dist/lib/ipc.js +32 -0
- package/server/dist/lib/kitsu.js +552 -0
- package/server/dist/lib/mal.js +886 -0
- package/server/dist/lib/migration.js +335 -0
- package/server/dist/lib/temp-ids.js +11 -0
- package/server/dist/lib/tmdb.js +71 -0
- package/server/dist/lib/tracker/anilist-tracker.js +250 -0
- package/server/dist/lib/tracker/sync.service.js +312 -0
- package/server/dist/logger.js +19 -0
- package/server/dist/providers/123anime.provider.js +240 -0
- package/server/dist/providers/anilight.provider.js +366 -0
- package/server/dist/providers/animepahe.provider.js +531 -0
- package/server/dist/providers/animeya.provider.js +746 -0
- package/server/dist/providers/base-provider.js +45 -0
- package/server/dist/providers/hn.provider.js +473 -0
- package/server/dist/providers/ht.provider.js +439 -0
- package/server/dist/providers/jasmr.provider.js +504 -0
- package/server/dist/providers/kaa.provider.js +388 -0
- package/server/dist/providers/manga/manga.types.js +10 -0
- package/server/dist/providers/manga/mangadex.provider.js +227 -0
- package/server/dist/providers/manga/mangapill.provider.js +232 -0
- package/server/dist/providers/megaplay.provider.js +583 -0
- package/server/dist/providers/op.provider.js +440 -0
- package/server/dist/providers/provider.interface.js +2 -0
- package/server/dist/providers/title-matching.js +234 -0
- package/server/dist/providers/wh.provider.js +600 -0
- package/server/dist/rclone.js +126 -0
- package/server/dist/repositories/insights.repository.js +43 -0
- package/server/dist/repositories/mal-cache.repository.js +66 -0
- package/server/dist/repositories/notifications.repository.js +40 -0
- package/server/dist/repositories/queue.repository.js +64 -0
- package/server/dist/repositories/settings.repository.js +15 -0
- package/server/dist/repositories/shows-meta.repository.js +43 -0
- package/server/dist/repositories/temp-show-ids.repository.js +54 -0
- package/server/dist/repositories/watched-episodes.repository.js +54 -0
- package/server/dist/repositories/watchlist.repository.js +74 -0
- package/server/dist/routes/asmr.routes.js +72 -0
- package/server/dist/routes/auth.routes.js +28 -0
- package/server/dist/routes/data.routes.js +50 -0
- package/server/dist/routes/discord-gateway.routes.js +57 -0
- package/server/dist/routes/insights.routes.js +12 -0
- package/server/dist/routes/lan-auth.routes.js +49 -0
- package/server/dist/routes/manga.routes.js +115 -0
- package/server/dist/routes/proxy.routes.js +15 -0
- package/server/dist/routes/radio.routes.js +143 -0
- package/server/dist/routes/settings.routes.js +27 -0
- package/server/dist/routes/tracker.routes.js +143 -0
- package/server/dist/routes/translate.routes.js +90 -0
- package/server/dist/routes/tv.routes.js +843 -0
- package/server/dist/routes/watchlist.routes.js +312 -0
- package/server/dist/server.js +310 -0
- package/server/dist/shipped-defaults.js +8 -0
- package/server/dist/sync-config.js +32 -0
- package/server/dist/sync.js +506 -0
- package/server/dist/utils/cookie.utils.js +17 -0
- package/server/dist/utils/db-utils.js +15 -0
- package/server/dist/utils/env.utils.js +83 -0
- package/server/dist/utils/machine-id.js +46 -0
- package/server/dist/utils/request-context.js +5 -0
- package/server/dist/utils/security.utils.js +99 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EN_TITLE_CACHE_PREFIX = void 0;
|
|
4
|
+
exports.parseSearchResults = parseSearchResults;
|
|
5
|
+
exports.parseDetail = parseDetail;
|
|
6
|
+
exports.toSearchEntry = toSearchEntry;
|
|
7
|
+
exports.toDetail = toDetail;
|
|
8
|
+
exports.toAnilistSearchMedia = toAnilistSearchMedia;
|
|
9
|
+
exports.toAnilistDetailMedia = toAnilistDetailMedia;
|
|
10
|
+
exports.malSearchAnime = malSearchAnime;
|
|
11
|
+
exports.malAnimeDetail = malAnimeDetail;
|
|
12
|
+
exports.enrichEnglishFromMalDetail = enrichEnglishFromMalDetail;
|
|
13
|
+
exports.enrichCardTitles = enrichCardTitles;
|
|
14
|
+
exports.malSearchMedia = malSearchMedia;
|
|
15
|
+
exports.malSearchTitle = malSearchTitle;
|
|
16
|
+
exports.malTop = malTop;
|
|
17
|
+
exports.malSeasonal = malSeasonal;
|
|
18
|
+
exports.malScheduleWeek = malScheduleWeek;
|
|
19
|
+
exports.malLatestReleases = malLatestReleases;
|
|
20
|
+
const kitsu_1 = require("./kitsu");
|
|
21
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
22
|
+
const MIN_GAP_MS = 1500;
|
|
23
|
+
const TIMEOUT_MS = 25000;
|
|
24
|
+
const DEFAULT_TTL_SECONDS = 6 * 3600;
|
|
25
|
+
let lastFetchAt = 0;
|
|
26
|
+
async function politeWait() {
|
|
27
|
+
const wait = MIN_GAP_MS - (Date.now() - lastFetchAt);
|
|
28
|
+
if (wait > 0)
|
|
29
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
30
|
+
lastFetchAt = Date.now();
|
|
31
|
+
}
|
|
32
|
+
async function fetchHtml(url) {
|
|
33
|
+
await politeWait();
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: 'follow',
|
|
38
|
+
});
|
|
39
|
+
const html = await res.text();
|
|
40
|
+
return { status: res.status, html, url: res.url };
|
|
41
|
+
}
|
|
42
|
+
function decodeHtml(s) {
|
|
43
|
+
return s
|
|
44
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
45
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
|
46
|
+
.replace(/&/g, '&')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/�?39;|'/g, "'")
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
function firstImage(chunk) {
|
|
54
|
+
const m = chunk.match(/\bdata-src=["']([^"']+)["']/i) || chunk.match(/\bsrc=["']((?!data:)[^"']+)["']/i);
|
|
55
|
+
if (!m)
|
|
56
|
+
return null;
|
|
57
|
+
const raw = m[1].replace(/&/g, '&');
|
|
58
|
+
const unwrapped = raw.match(/[?&]src=([^&]+)/i);
|
|
59
|
+
let url = raw;
|
|
60
|
+
if (unwrapped) {
|
|
61
|
+
try {
|
|
62
|
+
url = decodeURIComponent(unwrapped[1]);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
url = unwrapped[1];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (/^https:\/\/cdn\.myanimelist\.net\/r\/\d+x\d+\//i.test(url)) {
|
|
69
|
+
url = url.replace(/\/r\/\d+x\d+\//i, '/').split('?')[0];
|
|
70
|
+
}
|
|
71
|
+
return url;
|
|
72
|
+
}
|
|
73
|
+
function numeric(raw) {
|
|
74
|
+
if (!raw)
|
|
75
|
+
return null;
|
|
76
|
+
const cleaned = raw.replace(/,/g, '').trim();
|
|
77
|
+
if (/^(unknown|n\/a|-)$/i.test(cleaned))
|
|
78
|
+
return null;
|
|
79
|
+
const n = Number(cleaned);
|
|
80
|
+
return Number.isFinite(n) ? n : null;
|
|
81
|
+
}
|
|
82
|
+
function parseSearchCard(chunk) {
|
|
83
|
+
const idMatch = chunk.match(/id="sarea(\d+)"/);
|
|
84
|
+
if (!idMatch)
|
|
85
|
+
return null;
|
|
86
|
+
const malId = Number(idMatch[1]);
|
|
87
|
+
const href = chunk.match(new RegExp(`href="(https://myanimelist\\.net/anime/${malId}/[^"]*)"`, 'i'))?.[1];
|
|
88
|
+
const title = decodeHtml(chunk.match(/<strong>([^<]+)<\/strong>/i)?.[1] ?? '');
|
|
89
|
+
if (!malId || !title)
|
|
90
|
+
return null;
|
|
91
|
+
const synopsisRaw = chunk.match(/class="pt4">([\s\S]*?)<a\s/i)?.[1] ?? null;
|
|
92
|
+
const synopsis = synopsisRaw
|
|
93
|
+
? decodeHtml(synopsisRaw)
|
|
94
|
+
.replace(/\s*read more\.?$/i, '')
|
|
95
|
+
.replace(/\.{3}$/, '')
|
|
96
|
+
.trim() || null
|
|
97
|
+
: null;
|
|
98
|
+
const cells = [
|
|
99
|
+
...chunk.matchAll(/<td class="borderClass ac bgColor\d"[^>]*>\s*([\s\S]*?)\s*<\/td>/gi),
|
|
100
|
+
].map((m) => decodeHtml(m[1]));
|
|
101
|
+
return {
|
|
102
|
+
id: -malId,
|
|
103
|
+
idMal: malId,
|
|
104
|
+
url: href ?? `https://myanimelist.net/anime/${malId}`,
|
|
105
|
+
title,
|
|
106
|
+
imageUrl: firstImage(chunk),
|
|
107
|
+
synopsis,
|
|
108
|
+
type: cells[0] || null,
|
|
109
|
+
episodes: numeric(cells[1]),
|
|
110
|
+
score: numeric(cells[2]),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function parseSearchResults(html) {
|
|
114
|
+
if (!html.includes('Anime - MyAnimeList.net'))
|
|
115
|
+
return [];
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const chunk of html.split('<tr>').slice(1)) {
|
|
118
|
+
const entry = parseSearchCard(chunk);
|
|
119
|
+
if (entry && !out.some((e) => e.idMal === entry.idMal))
|
|
120
|
+
out.push(entry);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function metaContent(html, property) {
|
|
125
|
+
const m = html.match(new RegExp(`<meta\\s+property=["']${property}["']\\s+content=["']([^"']*)["']`, 'i'));
|
|
126
|
+
return m ? decodeHtml(m[1]) : null;
|
|
127
|
+
}
|
|
128
|
+
function infoField(html, label) {
|
|
129
|
+
const m = html.match(new RegExp(`<span class="dark_text">${label}:</span>\\s*([^<]*?)(?:<|$)`, 'i'));
|
|
130
|
+
return m ? decodeHtml(m[1]).trim() || null : null;
|
|
131
|
+
}
|
|
132
|
+
function parseDetail(html, malId) {
|
|
133
|
+
const title = metaContent(html, 'og:title') || decodeHtml(html.match(/<h1[^>]*>([^<]+)<\/h1>/i)?.[1] ?? '');
|
|
134
|
+
if (!title)
|
|
135
|
+
return null;
|
|
136
|
+
if (/^(404\b|not found\b)/i.test(title.trim()))
|
|
137
|
+
return null;
|
|
138
|
+
const rawImage = metaContent(html, 'og:image');
|
|
139
|
+
const imageUrl = rawImage && !/apple-touch-icon|404|error/i.test(rawImage) ? rawImage : null;
|
|
140
|
+
const genres = [...html.matchAll(/\/anime\/genre\/\d+\/[^"']*"[^>]*>([^<]+)</gi)]
|
|
141
|
+
.map((m) => decodeHtml(m[1]).trim())
|
|
142
|
+
.filter((g, i, a) => g && a.indexOf(g) === i)
|
|
143
|
+
.slice(0, 10);
|
|
144
|
+
return {
|
|
145
|
+
id: -malId,
|
|
146
|
+
idMal: malId,
|
|
147
|
+
url: metaContent(html, 'og:url') || `https://myanimelist.net/anime/${malId}`,
|
|
148
|
+
title,
|
|
149
|
+
titleEnglish: infoField(html, 'English'),
|
|
150
|
+
titleJapanese: infoField(html, 'Japanese'),
|
|
151
|
+
imageUrl,
|
|
152
|
+
synopsis: metaContent(html, 'og:description'),
|
|
153
|
+
type: infoField(html, 'Type'),
|
|
154
|
+
episodes: numeric(infoField(html, 'Episodes')),
|
|
155
|
+
status: infoField(html, 'Status'),
|
|
156
|
+
score: numeric(infoField(html, 'Score')),
|
|
157
|
+
rating: infoField(html, 'Rating'),
|
|
158
|
+
genres,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function detailToSearchEntry(d) {
|
|
162
|
+
return {
|
|
163
|
+
id: d.id,
|
|
164
|
+
idMal: d.idMal,
|
|
165
|
+
url: d.url,
|
|
166
|
+
title: d.title,
|
|
167
|
+
imageUrl: d.imageUrl,
|
|
168
|
+
synopsis: d.synopsis,
|
|
169
|
+
type: d.type,
|
|
170
|
+
episodes: d.episodes,
|
|
171
|
+
score: d.score,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function toSearchEntry(e) {
|
|
175
|
+
if (!e || !e.malId || !e.title)
|
|
176
|
+
return null;
|
|
177
|
+
return {
|
|
178
|
+
id: -e.malId,
|
|
179
|
+
idMal: e.malId,
|
|
180
|
+
url: e.url ?? `https://myanimelist.net/anime/${e.malId}`,
|
|
181
|
+
title: e.title,
|
|
182
|
+
imageUrl: e.imageUrl ?? null,
|
|
183
|
+
synopsis: e.synopsis ?? null,
|
|
184
|
+
type: e.type ?? null,
|
|
185
|
+
episodes: e.episodes ?? null,
|
|
186
|
+
score: e.score ?? null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function toDetail(e, malId) {
|
|
190
|
+
if (!e || !e.title)
|
|
191
|
+
return null;
|
|
192
|
+
const genres = (Array.isArray(e.genres) ? e.genres : [])
|
|
193
|
+
.map((g) => (typeof g === 'string' ? g : g?.name))
|
|
194
|
+
.filter((g) => !!g);
|
|
195
|
+
return {
|
|
196
|
+
id: -malId,
|
|
197
|
+
idMal: malId,
|
|
198
|
+
url: e.url ?? `https://myanimelist.net/anime/${malId}`,
|
|
199
|
+
title: e.title,
|
|
200
|
+
titleEnglish: e.titleEnglish ?? null,
|
|
201
|
+
titleJapanese: e.titleJapanese ?? null,
|
|
202
|
+
imageUrl: e.imageUrl ?? e.images?.medium ?? null,
|
|
203
|
+
synopsis: e.synopsis ?? null,
|
|
204
|
+
type: e.type ?? null,
|
|
205
|
+
episodes: e.episodes ?? null,
|
|
206
|
+
status: e.status ?? null,
|
|
207
|
+
score: e.score ?? null,
|
|
208
|
+
rating: e.rating ?? null,
|
|
209
|
+
genres,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function mapMalStatus(status) {
|
|
213
|
+
if (!status)
|
|
214
|
+
return undefined;
|
|
215
|
+
const s = status.toUpperCase().replace(/\s+/g, '_');
|
|
216
|
+
if (s.includes('FINISHED'))
|
|
217
|
+
return 'FINISHED';
|
|
218
|
+
if (s.includes('CURRENTLY_AIRING') || s === 'AIRING')
|
|
219
|
+
return 'RELEASING';
|
|
220
|
+
if (s.includes('NOT_YET_AIRED') || s === 'UPCOMING')
|
|
221
|
+
return 'NOT_YET_RELEASED';
|
|
222
|
+
return s;
|
|
223
|
+
}
|
|
224
|
+
function toAnilistSearchMedia(e) {
|
|
225
|
+
return {
|
|
226
|
+
id: e.id,
|
|
227
|
+
idMal: e.idMal,
|
|
228
|
+
title: { romaji: e.title, english: e.title, native: e.title },
|
|
229
|
+
coverImage: e.imageUrl
|
|
230
|
+
? { extraLarge: e.imageUrl, large: e.imageUrl, medium: e.imageUrl }
|
|
231
|
+
: undefined,
|
|
232
|
+
description: e.synopsis,
|
|
233
|
+
averageScore: e.score != null ? Math.round(e.score * 10) : null,
|
|
234
|
+
format: e.type?.toUpperCase(),
|
|
235
|
+
episodes: e.episodes,
|
|
236
|
+
siteUrl: e.url,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function toAnilistDetailMedia(e) {
|
|
240
|
+
const rating = e.rating || '';
|
|
241
|
+
return {
|
|
242
|
+
id: e.id,
|
|
243
|
+
idMal: e.idMal,
|
|
244
|
+
title: {
|
|
245
|
+
romaji: e.title,
|
|
246
|
+
english: e.titleEnglish || e.title,
|
|
247
|
+
native: e.titleJapanese || e.title,
|
|
248
|
+
},
|
|
249
|
+
coverImage: e.imageUrl
|
|
250
|
+
? { extraLarge: e.imageUrl, large: e.imageUrl, medium: e.imageUrl }
|
|
251
|
+
: undefined,
|
|
252
|
+
description: e.synopsis,
|
|
253
|
+
genres: e.genres.length > 0 ? e.genres : undefined,
|
|
254
|
+
averageScore: e.score != null ? Math.round(e.score * 10) : null,
|
|
255
|
+
format: e.type?.toUpperCase(),
|
|
256
|
+
status: mapMalStatus(e.status),
|
|
257
|
+
episodes: e.episodes,
|
|
258
|
+
isAdult: rating.includes('Rx') || rating.includes('R+'),
|
|
259
|
+
siteUrl: e.url,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async function malSearchAnime(store, query, page = 1) {
|
|
263
|
+
const key = `mal:search:anime:${query.toLowerCase()}:page:${page}`;
|
|
264
|
+
const hit = store.get(key);
|
|
265
|
+
if (hit && hit.fresh) {
|
|
266
|
+
return { entries: JSON.parse(hit.payload), cached: true, ms: 0 };
|
|
267
|
+
}
|
|
268
|
+
const t0 = Date.now();
|
|
269
|
+
const show = (page - 1) * 50;
|
|
270
|
+
const params = new URLSearchParams({ q: query, cat: 'anime' });
|
|
271
|
+
if (show > 0)
|
|
272
|
+
params.set('show', String(show));
|
|
273
|
+
const res = await fetchHtml(`https://myanimelist.net/anime.php?${params.toString()}`);
|
|
274
|
+
const ms = Date.now() - t0;
|
|
275
|
+
if (res.status !== 200) {
|
|
276
|
+
if (hit)
|
|
277
|
+
return { entries: JSON.parse(hit.payload), cached: true, ms };
|
|
278
|
+
throw new Error(`MAL search failed with status ${res.status}`);
|
|
279
|
+
}
|
|
280
|
+
const detailMatch = res.url.match(/myanimelist\.net\/anime\/(\d+)/i);
|
|
281
|
+
if (detailMatch && !res.html.includes('Search Anime - MyAnimeList.net')) {
|
|
282
|
+
const detail = parseDetail(res.html, Number(detailMatch[1]));
|
|
283
|
+
const entries = detail ? [detailToSearchEntry(detail)] : [];
|
|
284
|
+
if (entries.length > 0)
|
|
285
|
+
store.put(key, JSON.stringify(entries), DEFAULT_TTL_SECONDS);
|
|
286
|
+
return { entries, cached: false, ms };
|
|
287
|
+
}
|
|
288
|
+
const entries = parseSearchResults(res.html);
|
|
289
|
+
store.put(key, JSON.stringify(entries), DEFAULT_TTL_SECONDS);
|
|
290
|
+
return { entries, cached: false, ms };
|
|
291
|
+
}
|
|
292
|
+
async function malAnimeDetail(store, malId) {
|
|
293
|
+
const key = `mal:detail:anime:${malId}`;
|
|
294
|
+
const hit = store.get(key);
|
|
295
|
+
if (hit && hit.fresh) {
|
|
296
|
+
return { detail: JSON.parse(hit.payload), cached: true, ms: 0 };
|
|
297
|
+
}
|
|
298
|
+
const t0 = Date.now();
|
|
299
|
+
const { status, html } = await fetchHtml(`https://myanimelist.net/anime/${malId}`);
|
|
300
|
+
const ms = Date.now() - t0;
|
|
301
|
+
if (status !== 200) {
|
|
302
|
+
if (hit)
|
|
303
|
+
return { detail: JSON.parse(hit.payload), cached: true, ms };
|
|
304
|
+
throw new Error(`MAL detail failed with status ${status}`);
|
|
305
|
+
}
|
|
306
|
+
const detail = parseDetail(html, malId);
|
|
307
|
+
if (detail)
|
|
308
|
+
store.put(key, JSON.stringify(detail), DEFAULT_TTL_SECONDS);
|
|
309
|
+
return { detail, cached: false, ms };
|
|
310
|
+
}
|
|
311
|
+
const MAL_TYPE_IDS = {
|
|
312
|
+
tv: '1',
|
|
313
|
+
ova: '2',
|
|
314
|
+
movie: '3',
|
|
315
|
+
special: '4',
|
|
316
|
+
ona: '5',
|
|
317
|
+
music: '6',
|
|
318
|
+
};
|
|
319
|
+
const MAL_STATUS_IDS = {
|
|
320
|
+
airing: '1',
|
|
321
|
+
complete: '2',
|
|
322
|
+
finished: '2',
|
|
323
|
+
upcoming: '3',
|
|
324
|
+
hiatus: '3',
|
|
325
|
+
cancelled: undefined,
|
|
326
|
+
not_yet_released: '3',
|
|
327
|
+
};
|
|
328
|
+
const MAL_RATING_IDS = {
|
|
329
|
+
g: '1',
|
|
330
|
+
pg: '2',
|
|
331
|
+
pg13: '3',
|
|
332
|
+
r17: '4',
|
|
333
|
+
r: '5',
|
|
334
|
+
rx: '6',
|
|
335
|
+
};
|
|
336
|
+
const MAL_ORDER_IDS = {
|
|
337
|
+
start_date: '2',
|
|
338
|
+
score: '3',
|
|
339
|
+
episodes: '4',
|
|
340
|
+
end_date: '5',
|
|
341
|
+
type: '6',
|
|
342
|
+
members: '7',
|
|
343
|
+
rating: '8',
|
|
344
|
+
mal_id: '9',
|
|
345
|
+
};
|
|
346
|
+
const ORDER_BY_MAP = {
|
|
347
|
+
POPULARITY_DESC: 'members',
|
|
348
|
+
SCORE_DESC: 'score',
|
|
349
|
+
TRENDING_DESC: 'members',
|
|
350
|
+
START_DATE_DESC: 'start_date',
|
|
351
|
+
END_DATE_DESC: 'end_date',
|
|
352
|
+
EPISODES_DESC: 'episodes',
|
|
353
|
+
FAVOURITES_DESC: 'members',
|
|
354
|
+
UPDATED_AT_DESC: 'start_date',
|
|
355
|
+
};
|
|
356
|
+
const SEASON_MONTHS = {
|
|
357
|
+
WINTER: [1, 2, 3],
|
|
358
|
+
SPRING: [4, 5, 6],
|
|
359
|
+
SUMMER: [7, 8, 9],
|
|
360
|
+
FALL: [10, 11, 12],
|
|
361
|
+
};
|
|
362
|
+
const SEASON_START_MONTH = {
|
|
363
|
+
WINTER: '01',
|
|
364
|
+
SPRING: '04',
|
|
365
|
+
SUMMER: '07',
|
|
366
|
+
FALL: '10',
|
|
367
|
+
};
|
|
368
|
+
const MONTH_INDEX = {
|
|
369
|
+
jan: 1,
|
|
370
|
+
january: 1,
|
|
371
|
+
feb: 2,
|
|
372
|
+
february: 2,
|
|
373
|
+
mar: 3,
|
|
374
|
+
march: 3,
|
|
375
|
+
apr: 4,
|
|
376
|
+
april: 4,
|
|
377
|
+
may: 5,
|
|
378
|
+
jun: 6,
|
|
379
|
+
june: 6,
|
|
380
|
+
jul: 7,
|
|
381
|
+
july: 7,
|
|
382
|
+
aug: 8,
|
|
383
|
+
august: 8,
|
|
384
|
+
sep: 9,
|
|
385
|
+
sept: 9,
|
|
386
|
+
september: 9,
|
|
387
|
+
oct: 10,
|
|
388
|
+
october: 10,
|
|
389
|
+
nov: 11,
|
|
390
|
+
november: 11,
|
|
391
|
+
dec: 12,
|
|
392
|
+
december: 12,
|
|
393
|
+
};
|
|
394
|
+
const SCHEDULE_DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
395
|
+
const CARD_TYPE_BY_ID = {
|
|
396
|
+
'0': 'Unknown',
|
|
397
|
+
'1': 'TV',
|
|
398
|
+
'2': 'OVA',
|
|
399
|
+
'3': 'Movie',
|
|
400
|
+
'4': 'Special',
|
|
401
|
+
'5': 'ONA',
|
|
402
|
+
'9': 'TV Special',
|
|
403
|
+
};
|
|
404
|
+
async function resolveGenreIds(store, genre) {
|
|
405
|
+
const cached = store.get('taxonomy:anime:genres');
|
|
406
|
+
let map;
|
|
407
|
+
if (cached && cached.fresh) {
|
|
408
|
+
map = JSON.parse(cached.payload);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const { status, html } = await fetchHtml('https://myanimelist.net/anime.php?cat=genre');
|
|
412
|
+
if (status !== 200) {
|
|
413
|
+
if (cached) {
|
|
414
|
+
map = JSON.parse(cached.payload);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
map = {};
|
|
422
|
+
for (const m of html.matchAll(/\/anime\/genre\/(\d+)\/[^"']*"[^>]*>([^<]+)</gi)) {
|
|
423
|
+
map[decodeHtml(m[2]).trim().toLowerCase()] = Number(m[1]);
|
|
424
|
+
}
|
|
425
|
+
store.put('taxonomy:anime:genres', JSON.stringify(map), 24 * 3600);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const ids = [];
|
|
429
|
+
for (const part of genre.split(',')) {
|
|
430
|
+
const name = part.trim().toLowerCase();
|
|
431
|
+
if (!name)
|
|
432
|
+
continue;
|
|
433
|
+
if (/^\d+$/.test(name)) {
|
|
434
|
+
ids.push(parseInt(name, 10));
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const id = map[name];
|
|
438
|
+
if (id == null)
|
|
439
|
+
return null;
|
|
440
|
+
ids.push(id);
|
|
441
|
+
}
|
|
442
|
+
return ids.length > 0 ? [...new Set(ids)].join(',') : null;
|
|
443
|
+
}
|
|
444
|
+
function parseSeasonalCard(chunk) {
|
|
445
|
+
const link = chunk.match(/<a href="https:\/\/myanimelist\.net\/anime\/(\d+)\/[^"]*" class="link-title">([^<]+)<\/a>/i);
|
|
446
|
+
if (!link)
|
|
447
|
+
return null;
|
|
448
|
+
const typeId = chunk.slice(0, 200).match(/js-anime-type-(\d+)/)?.[1];
|
|
449
|
+
return {
|
|
450
|
+
malId: Number(link[1]),
|
|
451
|
+
title: decodeHtml(link[2]),
|
|
452
|
+
imageUrl: firstImage(chunk),
|
|
453
|
+
score: numeric(chunk.match(/class="js-score">([\d.]+)<\/span>/i)?.[1] ?? null),
|
|
454
|
+
type: typeId === undefined ? null : (CARD_TYPE_BY_ID[typeId] ?? null),
|
|
455
|
+
episodes: numeric(chunk.match(/<span>(\d+)\s*eps?<\/span>/i)?.[1] ?? null),
|
|
456
|
+
startDate: chunk.match(/class="js-start_date">(\d+)<\/span>/i)?.[1] ?? null,
|
|
457
|
+
members: numeric(chunk.match(/class="js-members">(\d+)<\/span>/i)?.[1] ?? null),
|
|
458
|
+
genreIds: (chunk.match(/data-genre="([\d,]+)"/i)?.[1] ?? '')
|
|
459
|
+
.split(',')
|
|
460
|
+
.map((n) => Number(n))
|
|
461
|
+
.filter((n) => Number.isFinite(n) && n > 0),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function parseSeasonalCards(html) {
|
|
465
|
+
const out = new Map();
|
|
466
|
+
for (const chunk of html.split('js-seasonal-anime').slice(1)) {
|
|
467
|
+
const card = parseSeasonalCard(chunk);
|
|
468
|
+
if (card)
|
|
469
|
+
out.set(card.malId, card);
|
|
470
|
+
}
|
|
471
|
+
return [...out.values()];
|
|
472
|
+
}
|
|
473
|
+
function startDateParts(value) {
|
|
474
|
+
const m = value?.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
475
|
+
if (m)
|
|
476
|
+
return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
|
|
477
|
+
const t = value?.match(/([A-Za-z]+)\s+(\d{4})/);
|
|
478
|
+
if (t) {
|
|
479
|
+
const month = MONTH_INDEX[t[1].toLowerCase()];
|
|
480
|
+
if (month)
|
|
481
|
+
return { year: Number(t[2]), month };
|
|
482
|
+
}
|
|
483
|
+
return {};
|
|
484
|
+
}
|
|
485
|
+
function cardToMedia(c) {
|
|
486
|
+
const startDate = startDateParts(c.startDate);
|
|
487
|
+
const cover = c.imageUrl || undefined;
|
|
488
|
+
return {
|
|
489
|
+
id: -c.malId,
|
|
490
|
+
idMal: c.malId,
|
|
491
|
+
title: { romaji: c.title, english: c.title, native: c.title },
|
|
492
|
+
coverImage: cover ? { extraLarge: cover, large: cover, medium: cover } : undefined,
|
|
493
|
+
description: null,
|
|
494
|
+
averageScore: c.score != null ? Math.round(c.score * 10) : null,
|
|
495
|
+
format: c.type?.toUpperCase(),
|
|
496
|
+
episodes: c.episodes,
|
|
497
|
+
seasonYear: startDate.year,
|
|
498
|
+
startDate,
|
|
499
|
+
popularity: c.members ?? undefined,
|
|
500
|
+
isAdult: c.genreIds.includes(12),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function cachedFetch(store, key, url) {
|
|
504
|
+
const hit = store.get(key);
|
|
505
|
+
if (hit && hit.fresh)
|
|
506
|
+
return Promise.resolve({ html: hit.payload, cached: true });
|
|
507
|
+
return fetchHtml(url).then(({ status, html }) => {
|
|
508
|
+
if (status !== 200) {
|
|
509
|
+
if (hit)
|
|
510
|
+
return { html: hit.payload, cached: true };
|
|
511
|
+
throw new Error(`MAL fetch failed with status ${status} for ${url}`);
|
|
512
|
+
}
|
|
513
|
+
store.put(key, html, DEFAULT_TTL_SECONDS);
|
|
514
|
+
return { html, cached: false };
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
const TITLE_TTL_SECONDS = 7 * 24 * 3600;
|
|
518
|
+
const EN_TITLE_TTL_SECONDS = 7 * 24 * 3600;
|
|
519
|
+
const EN_TITLE_FETCH_TIMEOUT_MS = 10000;
|
|
520
|
+
const EN_TITLE_TOP_N = 10;
|
|
521
|
+
exports.EN_TITLE_CACHE_PREFIX = 'mal:en-title:';
|
|
522
|
+
function needsEnglishTitle(m) {
|
|
523
|
+
const romaji = m.title?.romaji?.trim() ?? '';
|
|
524
|
+
const english = m.title?.english?.trim() ?? '';
|
|
525
|
+
return !!m.idMal && (!english || (!!romaji && english === romaji));
|
|
526
|
+
}
|
|
527
|
+
function cleanEnTitle(raw, romaji) {
|
|
528
|
+
const t = (raw ?? '').trim();
|
|
529
|
+
if (!t || /^(unknown|n\/a|-)$/i.test(t))
|
|
530
|
+
return null;
|
|
531
|
+
if (romaji && t.toLowerCase() === romaji.trim().toLowerCase())
|
|
532
|
+
return null;
|
|
533
|
+
return t;
|
|
534
|
+
}
|
|
535
|
+
async function enrichEnglishFromMalDetail(store, items, limit = EN_TITLE_TOP_N) {
|
|
536
|
+
const targets = items.filter(needsEnglishTitle).slice(0, Math.max(0, limit));
|
|
537
|
+
if (targets.length === 0)
|
|
538
|
+
return items;
|
|
539
|
+
await Promise.all(targets.map(async (m) => {
|
|
540
|
+
const malId = m.idMal;
|
|
541
|
+
const key = `${exports.EN_TITLE_CACHE_PREFIX}${malId}`;
|
|
542
|
+
try {
|
|
543
|
+
const hit = store.get(key);
|
|
544
|
+
if (hit && typeof hit.payload === 'string') {
|
|
545
|
+
const titleEnglish = cleanEnTitle(hit.payload, m.title?.romaji);
|
|
546
|
+
if (titleEnglish && m.title)
|
|
547
|
+
m.title = { ...m.title, english: titleEnglish };
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const res = await fetch(`https://myanimelist.net/anime/${malId}`, {
|
|
551
|
+
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
|
|
552
|
+
signal: AbortSignal.timeout(EN_TITLE_FETCH_TIMEOUT_MS),
|
|
553
|
+
redirect: 'follow',
|
|
554
|
+
});
|
|
555
|
+
if (res.status !== 200)
|
|
556
|
+
return;
|
|
557
|
+
const html = await res.text();
|
|
558
|
+
const raw = infoField(html, 'English');
|
|
559
|
+
try {
|
|
560
|
+
store.put(key, (raw ?? '').trim(), EN_TITLE_TTL_SECONDS);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// ignore
|
|
564
|
+
}
|
|
565
|
+
const titleEnglish = cleanEnTitle(raw, m.title?.romaji);
|
|
566
|
+
if (!titleEnglish)
|
|
567
|
+
return;
|
|
568
|
+
if (m.title)
|
|
569
|
+
m.title = { ...m.title, english: titleEnglish };
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// ignore
|
|
573
|
+
}
|
|
574
|
+
}));
|
|
575
|
+
return items;
|
|
576
|
+
}
|
|
577
|
+
async function enrichCardTitles(store, items) {
|
|
578
|
+
const cached = new Map();
|
|
579
|
+
const missing = [];
|
|
580
|
+
for (const m of items) {
|
|
581
|
+
if (!m.idMal)
|
|
582
|
+
continue;
|
|
583
|
+
const hit = store.get(`mal:title:${m.idMal}`);
|
|
584
|
+
if (hit) {
|
|
585
|
+
try {
|
|
586
|
+
cached.set(m.idMal, JSON.parse(hit.payload));
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// ignore
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
missing.push(m.idMal);
|
|
594
|
+
}
|
|
595
|
+
let fetched = new Map();
|
|
596
|
+
if (missing.length > 0) {
|
|
597
|
+
try {
|
|
598
|
+
fetched = await (0, kitsu_1.kitsuTitlesByMalIds)(missing);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// ignore
|
|
602
|
+
}
|
|
603
|
+
for (const [id, t] of fetched) {
|
|
604
|
+
store.put(`mal:title:${id}`, JSON.stringify(t), TITLE_TTL_SECONDS);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const m of items) {
|
|
608
|
+
if (!m.idMal)
|
|
609
|
+
continue;
|
|
610
|
+
const t = cached.get(m.idMal) ?? fetched.get(m.idMal);
|
|
611
|
+
if (t?.english || t?.native) {
|
|
612
|
+
m.title = {
|
|
613
|
+
romaji: m.title?.romaji,
|
|
614
|
+
english: t.english ?? m.title?.english,
|
|
615
|
+
native: t.native ?? m.title?.native,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return items;
|
|
620
|
+
}
|
|
621
|
+
async function malSearchMedia(store, options = {}, enrichTitles = true) {
|
|
622
|
+
const { query, page = 1, format, status, season, seasonYear, genre, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
|
|
623
|
+
const params = new URLSearchParams();
|
|
624
|
+
const hasQuery = !!query?.trim();
|
|
625
|
+
if (hasQuery)
|
|
626
|
+
params.set('q', query.trim());
|
|
627
|
+
params.set('cat', 'anime');
|
|
628
|
+
if (page > 1)
|
|
629
|
+
params.set('show', String((page - 1) * 50));
|
|
630
|
+
const clientFormat = isAdult ? format : undefined;
|
|
631
|
+
if (format && format !== 'ALL' && format !== 'ADULT' && !isAdult) {
|
|
632
|
+
const typeId = MAL_TYPE_IDS[format.toLowerCase()];
|
|
633
|
+
if (typeId)
|
|
634
|
+
params.set('type', typeId);
|
|
635
|
+
}
|
|
636
|
+
if (status) {
|
|
637
|
+
const statusId = MAL_STATUS_IDS[status.toLowerCase()];
|
|
638
|
+
if (statusId)
|
|
639
|
+
params.set('status', statusId);
|
|
640
|
+
}
|
|
641
|
+
if (genre) {
|
|
642
|
+
const ids = await resolveGenreIds(store, genre);
|
|
643
|
+
if (!ids)
|
|
644
|
+
return [];
|
|
645
|
+
for (const id of ids.split(','))
|
|
646
|
+
params.append('genre[]', id);
|
|
647
|
+
}
|
|
648
|
+
if (seasonYear) {
|
|
649
|
+
const month = season && season !== 'ALL' ? (SEASON_START_MONTH[season.toUpperCase()] ?? '01') : '01';
|
|
650
|
+
params.set('sm', String(Number(month)));
|
|
651
|
+
params.set('sd', '1');
|
|
652
|
+
params.set('sy', String(seasonYear));
|
|
653
|
+
}
|
|
654
|
+
if (averageScore_greater)
|
|
655
|
+
params.set('score', String(averageScore_greater));
|
|
656
|
+
if (isAdult) {
|
|
657
|
+
params.set('r', '6');
|
|
658
|
+
params.append('genre[]', '12');
|
|
659
|
+
}
|
|
660
|
+
const wantOrder = sort ? ORDER_BY_MAP[sort] || 'members' : undefined;
|
|
661
|
+
const wantDir = sort && sort.includes('ASC') ? '2' : '1';
|
|
662
|
+
if (wantOrder) {
|
|
663
|
+
params.set('o', MAL_ORDER_IDS[wantOrder]);
|
|
664
|
+
params.set('w', wantDir);
|
|
665
|
+
}
|
|
666
|
+
const url = `https://myanimelist.net/anime.php?${params.toString()}`;
|
|
667
|
+
const key = `mal:search:${params.toString()}:page:${page}`;
|
|
668
|
+
const parseHtml = (html) => parseSearchResults(html).map(toAnilistSearchMedia);
|
|
669
|
+
const finish = (list) => enrichTitles ? enrichCardTitles(store, list) : Promise.resolve(list);
|
|
670
|
+
let serverSorted = false;
|
|
671
|
+
let html;
|
|
672
|
+
try {
|
|
673
|
+
const hit = store.get(key);
|
|
674
|
+
if (hit && hit.fresh) {
|
|
675
|
+
return finish(filterMalSearch(parseSearchResults(hit.payload).map(toAnilistSearchMedia), {
|
|
676
|
+
genre_not_in,
|
|
677
|
+
episodes_greater,
|
|
678
|
+
format: clientFormat,
|
|
679
|
+
seasonYear,
|
|
680
|
+
season,
|
|
681
|
+
sort: serverSorted ? undefined : sort,
|
|
682
|
+
hasQuery,
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
const res = await fetchHtml(url);
|
|
686
|
+
if (res.status !== 200) {
|
|
687
|
+
if (hit) {
|
|
688
|
+
return finish(filterMalSearch(parseSearchResults(hit.payload).map(toAnilistSearchMedia), {
|
|
689
|
+
genre_not_in,
|
|
690
|
+
episodes_greater,
|
|
691
|
+
format: clientFormat,
|
|
692
|
+
seasonYear,
|
|
693
|
+
season: undefined,
|
|
694
|
+
sort: undefined,
|
|
695
|
+
hasQuery,
|
|
696
|
+
}));
|
|
697
|
+
}
|
|
698
|
+
throw new Error(`MAL search failed with status ${res.status}`);
|
|
699
|
+
}
|
|
700
|
+
const detailMatch = res.url.match(/myanimelist\.net\/anime\/(\d+)/i);
|
|
701
|
+
if (detailMatch && !res.html.includes('Search Anime - MyAnimeList.net')) {
|
|
702
|
+
const detail = parseDetail(res.html, Number(detailMatch[1]));
|
|
703
|
+
const list = detail ? [toAnilistDetailMedia(detail)] : [];
|
|
704
|
+
store.put(key, JSON.stringify(list.map((m) => m.idMal)));
|
|
705
|
+
return list;
|
|
706
|
+
}
|
|
707
|
+
serverSorted = !!wantOrder;
|
|
708
|
+
store.put(key, res.html, DEFAULT_TTL_SECONDS);
|
|
709
|
+
html = res.html;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
const hit = store.get(key);
|
|
713
|
+
if (hit) {
|
|
714
|
+
try {
|
|
715
|
+
const cached = JSON.parse(hit.payload);
|
|
716
|
+
if (Array.isArray(cached) && cached.every((e) => typeof e === 'number')) {
|
|
717
|
+
return [];
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// ignore
|
|
722
|
+
}
|
|
723
|
+
return finish(filterMalSearch(parseSearchResults(hit.payload).map(toAnilistSearchMedia), {
|
|
724
|
+
genre_not_in,
|
|
725
|
+
episodes_greater,
|
|
726
|
+
format: clientFormat,
|
|
727
|
+
seasonYear,
|
|
728
|
+
season: undefined,
|
|
729
|
+
sort: undefined,
|
|
730
|
+
hasQuery,
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
return finish(filterMalSearch(parseSearchResults(html).map(toAnilistSearchMedia), {
|
|
736
|
+
genre_not_in,
|
|
737
|
+
episodes_greater,
|
|
738
|
+
format: clientFormat,
|
|
739
|
+
seasonYear,
|
|
740
|
+
season,
|
|
741
|
+
sort: serverSorted ? undefined : sort,
|
|
742
|
+
hasQuery,
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
function filterMalSearch(results, opts) {
|
|
746
|
+
let out = results;
|
|
747
|
+
if (opts.format && opts.format !== 'ALL' && opts.format !== 'ADULT') {
|
|
748
|
+
const f = opts.format.toUpperCase();
|
|
749
|
+
out = out.filter((m) => (m.format ?? '').toUpperCase() === f);
|
|
750
|
+
}
|
|
751
|
+
if (opts.episodes_greater != null) {
|
|
752
|
+
out = out.filter((m) => (m.episodes ?? 0) > opts.episodes_greater);
|
|
753
|
+
}
|
|
754
|
+
if (opts.seasonYear && !opts.hasQuery) {
|
|
755
|
+
out = out.filter((m) => m.startDate?.year === opts.seasonYear);
|
|
756
|
+
}
|
|
757
|
+
if (opts.season && opts.season !== 'ALL' && !opts.hasQuery) {
|
|
758
|
+
const months = SEASON_MONTHS[opts.season.toUpperCase()] ?? [];
|
|
759
|
+
out = out.filter((m) => m.startDate?.month != null && months.includes(m.startDate.month));
|
|
760
|
+
}
|
|
761
|
+
if (opts.sort &&
|
|
762
|
+
!opts.sort.startsWith('POPULARITY_') &&
|
|
763
|
+
!opts.sort.startsWith('TRENDING_') &&
|
|
764
|
+
!opts.sort.startsWith('FAVOURITES_') &&
|
|
765
|
+
!opts.sort.startsWith('START_DATE_') &&
|
|
766
|
+
!opts.sort.startsWith('END_DATE_')) {
|
|
767
|
+
const desc = !opts.sort.includes('ASC');
|
|
768
|
+
const dir = desc ? -1 : 1;
|
|
769
|
+
if (opts.sort.startsWith('SCORE_')) {
|
|
770
|
+
const scoreOf = (m) => m.averageScore ?? m.meanScore ?? -1;
|
|
771
|
+
out = [...out].sort((a, b) => (scoreOf(a) - scoreOf(b)) * dir);
|
|
772
|
+
}
|
|
773
|
+
else if (opts.sort.startsWith('EPISODES_')) {
|
|
774
|
+
out = [...out].sort((a, b) => ((a.episodes ?? -1) - (b.episodes ?? -1)) * dir);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
async function malSearchTitle(store, title) {
|
|
780
|
+
const results = await malSearchMedia(store, { query: title, page: 1, perPage: 5 }, false);
|
|
781
|
+
const first = results[0];
|
|
782
|
+
if (!first?.idMal)
|
|
783
|
+
return null;
|
|
784
|
+
return `mal-${first.idMal}`;
|
|
785
|
+
}
|
|
786
|
+
async function malTop(store, filter, page = 1) {
|
|
787
|
+
const limit = (page - 1) * 50;
|
|
788
|
+
const params = new URLSearchParams();
|
|
789
|
+
if (filter)
|
|
790
|
+
params.set('type', filter);
|
|
791
|
+
if (limit > 0)
|
|
792
|
+
params.set('limit', String(limit));
|
|
793
|
+
const query = params.toString();
|
|
794
|
+
const url = query
|
|
795
|
+
? `https://myanimelist.net/topanime.php?${query}`
|
|
796
|
+
: 'https://myanimelist.net/topanime.php';
|
|
797
|
+
const key = `mal:top:${filter ?? 'all'}:page:${page}`;
|
|
798
|
+
const { html } = await cachedFetch(store, key, url);
|
|
799
|
+
const out = [];
|
|
800
|
+
for (const match of html.matchAll(/<tr class="ranking-list">([\s\S]*?)<\/tr>/gi)) {
|
|
801
|
+
const row = match[1];
|
|
802
|
+
const malId = Number(row.match(/href="https:\/\/myanimelist\.net\/anime\/(\d+)\//i)?.[1]);
|
|
803
|
+
if (!malId)
|
|
804
|
+
continue;
|
|
805
|
+
const imgTag = row.match(/<img[^>]*alt="Anime: ([^"]+)"[^>]*>/i);
|
|
806
|
+
const title = imgTag ? decodeHtml(imgTag[1]) : '';
|
|
807
|
+
if (!title)
|
|
808
|
+
continue;
|
|
809
|
+
const info = row.match(/<div class="information[^"]*">([\s\S]*?)<\/div>/i)?.[1] ?? '';
|
|
810
|
+
const segments = info.split(/<br\s*\/?>/i).map((s) => decodeHtml(s));
|
|
811
|
+
const typeEpisodes = segments[0] ?? '';
|
|
812
|
+
const dateText = segments[1] ?? '';
|
|
813
|
+
const membersText = segments[2] ?? '';
|
|
814
|
+
const dateMatch = dateText.match(/([A-Za-z]+)\s+(\d{4})/);
|
|
815
|
+
const month = dateMatch ? MONTH_INDEX[dateMatch[1].toLowerCase()] : undefined;
|
|
816
|
+
const score = numeric(row.match(/class="text on score-label[^"]*">([\d.]+)<\/span>/i)?.[1] ?? null);
|
|
817
|
+
out.push({
|
|
818
|
+
id: -malId,
|
|
819
|
+
idMal: malId,
|
|
820
|
+
title: { romaji: title, english: title, native: title },
|
|
821
|
+
coverImage: undefined,
|
|
822
|
+
description: null,
|
|
823
|
+
averageScore: score != null ? Math.round(score * 10) : null,
|
|
824
|
+
format: typeEpisodes.match(/^(\S+)/)?.[1]?.toUpperCase(),
|
|
825
|
+
episodes: (() => {
|
|
826
|
+
const m = typeEpisodes.match(/\((\d+)/);
|
|
827
|
+
return m ? Number(m[1]) : null;
|
|
828
|
+
})(),
|
|
829
|
+
startDate: month != null && dateMatch ? { year: Number(dateMatch[2]), month } : undefined,
|
|
830
|
+
popularity: numeric(membersText.match(/([\d,]+)/)?.[1] ?? null) ?? undefined,
|
|
831
|
+
});
|
|
832
|
+
const img = row.match(/<img[^>]*>/i)?.[0] ?? '';
|
|
833
|
+
const imageUrl = firstImage(img);
|
|
834
|
+
if (imageUrl) {
|
|
835
|
+
const last = out[out.length - 1];
|
|
836
|
+
last.coverImage = { extraLarge: imageUrl, large: imageUrl, medium: imageUrl };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return enrichCardTitles(store, out);
|
|
840
|
+
}
|
|
841
|
+
async function malSeasonal(store, season, year, format, page = 1, size = 14) {
|
|
842
|
+
const key = `mal:season:${year}:${season.toLowerCase()}`;
|
|
843
|
+
const { html } = await cachedFetch(store, key, `https://myanimelist.net/anime/season/${year}/${season.toLowerCase()}`);
|
|
844
|
+
let all = parseSeasonalCards(html).map(cardToMedia);
|
|
845
|
+
if (format && format !== 'ALL' && format !== 'ADULT') {
|
|
846
|
+
const f = format.toUpperCase();
|
|
847
|
+
all = all.filter((m) => (m.format ?? '').toUpperCase() === f);
|
|
848
|
+
}
|
|
849
|
+
all = [...all].sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0));
|
|
850
|
+
const offset = (page - 1) * size;
|
|
851
|
+
return enrichCardTitles(store, all.slice(offset, offset + size));
|
|
852
|
+
}
|
|
853
|
+
async function malScheduleWeek(store) {
|
|
854
|
+
const key = 'mal:schedule:week';
|
|
855
|
+
const { html } = await cachedFetch(store, key, 'https://myanimelist.net/anime/season/schedule');
|
|
856
|
+
const out = {};
|
|
857
|
+
for (const section of html.split('class="anime-header">').slice(1)) {
|
|
858
|
+
const day = section
|
|
859
|
+
.slice(0, 40)
|
|
860
|
+
.match(/^([A-Za-z]+)</)?.[1]
|
|
861
|
+
?.toLowerCase();
|
|
862
|
+
if (!day || !SCHEDULE_DAYS.includes(day))
|
|
863
|
+
continue;
|
|
864
|
+
out[day] = parseSeasonalCards(section).map(cardToMedia);
|
|
865
|
+
}
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
async function malLatestReleases(store, format = 'TV', page = 1, size = 12) {
|
|
869
|
+
const week = await malScheduleWeek(store);
|
|
870
|
+
const seen = new Map();
|
|
871
|
+
for (const day of SCHEDULE_DAYS) {
|
|
872
|
+
for (const m of week[day] ?? []) {
|
|
873
|
+
if (!seen.has(m.id))
|
|
874
|
+
seen.set(m.id, m);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
let all = [...seen.values()];
|
|
878
|
+
if (format && format !== 'ALL') {
|
|
879
|
+
const f = format.toUpperCase();
|
|
880
|
+
all = all.filter((m) => (m.format ?? '').toUpperCase() === f);
|
|
881
|
+
}
|
|
882
|
+
const rank = (m) => (m.startDate?.year ?? 0) * 10000 + (m.startDate?.month ?? 0) * 100 + (m.startDate?.day ?? 0);
|
|
883
|
+
all.sort((a, b) => rank(b) - rank(a) || (b.popularity ?? 0) - (a.popularity ?? 0));
|
|
884
|
+
const offset = (page - 1) * size;
|
|
885
|
+
return enrichCardTitles(store, all.slice(offset, offset + size));
|
|
886
|
+
}
|