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