ichime-ts-api-client 1.0.0
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/README.md +105 -0
- package/biome.json +52 -0
- package/package.json +49 -0
- package/src/api/api-client.ts +173 -0
- package/src/api/date-decoder.ts +57 -0
- package/src/api/errors.ts +43 -0
- package/src/api/index.ts +4 -0
- package/src/api/types/episode.ts +16 -0
- package/src/api/types/index.ts +15 -0
- package/src/api/types/series-type.ts +10 -0
- package/src/api/types/series.ts +44 -0
- package/src/api/types/translation.ts +30 -0
- package/src/http-session.ts +99 -0
- package/src/index.ts +53 -0
- package/src/web/errors.ts +43 -0
- package/src/web/helpers.ts +70 -0
- package/src/web/index.ts +4 -0
- package/src/web/types/anime-list-status.ts +70 -0
- package/src/web/types/anime-list.ts +23 -0
- package/src/web/types/index.ts +21 -0
- package/src/web/types/moment.ts +25 -0
- package/src/web/types/personal-episode.ts +19 -0
- package/src/web/types/profile.ts +5 -0
- package/src/web/web-client.ts +810 -0
- package/tests/auth.test.ts +92 -0
- package/tsconfig.json +24 -0
- package/tsdown.config.ts +11 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
import type { Element } from "domhandler";
|
|
3
|
+
import type { HttpSession } from "../http-session.js";
|
|
4
|
+
import { WebClientError } from "./errors.js";
|
|
5
|
+
import {
|
|
6
|
+
extractIdentifiersFromUrl,
|
|
7
|
+
parseDurationString,
|
|
8
|
+
parseWebDate,
|
|
9
|
+
} from "./helpers.js";
|
|
10
|
+
import type {
|
|
11
|
+
AnimeListCategory,
|
|
12
|
+
AnimeListEditableEntry,
|
|
13
|
+
AnimeListEntry,
|
|
14
|
+
MomentDetails,
|
|
15
|
+
MomentEmbed,
|
|
16
|
+
MomentPreview,
|
|
17
|
+
MomentSorting,
|
|
18
|
+
NewPersonalEpisode,
|
|
19
|
+
NewRecentEpisode,
|
|
20
|
+
Profile,
|
|
21
|
+
VideoSource,
|
|
22
|
+
} from "./types/index.js";
|
|
23
|
+
import {
|
|
24
|
+
AnimeListCategoryWebPath,
|
|
25
|
+
animeListEntryStatusFromNumericId,
|
|
26
|
+
} from "./types/index.js";
|
|
27
|
+
|
|
28
|
+
const COOKIE_NAME_CSRF = "csrf";
|
|
29
|
+
const FORM_DATA_FIELD_CSRF = "csrf";
|
|
30
|
+
|
|
31
|
+
export class WebClient {
|
|
32
|
+
constructor(private readonly session: HttpSession) {}
|
|
33
|
+
|
|
34
|
+
get baseUrl(): string {
|
|
35
|
+
return this.session.baseUrl;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async sendRequest(
|
|
39
|
+
path: string,
|
|
40
|
+
queryItems: Record<string, string> = {},
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const url = new URL(path, this.session.baseUrl);
|
|
43
|
+
|
|
44
|
+
// Сортируем параметры по имени (как в Swift)
|
|
45
|
+
const sortedParams = Object.entries(queryItems).sort(([a], [b]) =>
|
|
46
|
+
a.localeCompare(b),
|
|
47
|
+
);
|
|
48
|
+
for (const [key, value] of sortedParams) {
|
|
49
|
+
url.searchParams.set(key, value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let response: Response;
|
|
53
|
+
try {
|
|
54
|
+
response = await this.session.request(url.pathname + url.search, {
|
|
55
|
+
method: "GET",
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw WebClientError.unknownError(
|
|
59
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (response.status >= 400) {
|
|
64
|
+
throw WebClientError.badStatusCode(response.status);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let html: string;
|
|
68
|
+
try {
|
|
69
|
+
html = await response.text();
|
|
70
|
+
} catch {
|
|
71
|
+
throw WebClientError.couldNotConvertResponseDataToString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return html;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async sendPostRequest(
|
|
78
|
+
path: string,
|
|
79
|
+
queryItems: Record<string, string> = {},
|
|
80
|
+
formData: Record<string, string> = {},
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const url = new URL(path, this.session.baseUrl);
|
|
83
|
+
|
|
84
|
+
// Сортируем параметры по имени
|
|
85
|
+
const sortedParams = Object.entries(queryItems).sort(([a], [b]) =>
|
|
86
|
+
a.localeCompare(b),
|
|
87
|
+
);
|
|
88
|
+
for (const [key, value] of sortedParams) {
|
|
89
|
+
url.searchParams.set(key, value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Получаем или создаем CSRF токен
|
|
93
|
+
let csrfToken = await this.session.getCookie(COOKIE_NAME_CSRF);
|
|
94
|
+
if (!csrfToken) {
|
|
95
|
+
csrfToken = crypto.randomUUID();
|
|
96
|
+
await this.session.setCookie(COOKIE_NAME_CSRF, csrfToken);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Добавляем CSRF токен в form data
|
|
100
|
+
const formDataWithCsrf = {
|
|
101
|
+
...formData,
|
|
102
|
+
[FORM_DATA_FIELD_CSRF]: csrfToken,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Формируем body как URL-encoded
|
|
106
|
+
const body = new URLSearchParams(formDataWithCsrf).toString();
|
|
107
|
+
|
|
108
|
+
let response: Response;
|
|
109
|
+
try {
|
|
110
|
+
response = await this.session.request(url.pathname + url.search, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
114
|
+
},
|
|
115
|
+
body,
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw WebClientError.unknownError(
|
|
119
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (response.status >= 400) {
|
|
124
|
+
throw WebClientError.badStatusCode(response.status);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let html: string;
|
|
128
|
+
try {
|
|
129
|
+
html = await response.text();
|
|
130
|
+
} catch {
|
|
131
|
+
throw WebClientError.couldNotConvertResponseDataToString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return html;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
parseHtml(html: string): cheerio.CheerioAPI {
|
|
138
|
+
return cheerio.load(html);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// === Login ===
|
|
142
|
+
|
|
143
|
+
async login(username: string, password: string): Promise<void> {
|
|
144
|
+
const html = await this.sendPostRequest(
|
|
145
|
+
"/users/login",
|
|
146
|
+
{},
|
|
147
|
+
{
|
|
148
|
+
"LoginForm[username]": username,
|
|
149
|
+
"LoginForm[password]": password,
|
|
150
|
+
dynpage: "1",
|
|
151
|
+
yt0: "",
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (html.includes("Неверный E-mail или пароль.") || html.includes("Вход по паролю")) {
|
|
156
|
+
throw new WebClientError("Invalid credentials");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// === Profile ===
|
|
161
|
+
|
|
162
|
+
async getProfile(): Promise<Profile> {
|
|
163
|
+
const html = await this.sendRequest("/users/profile", { dynpage: "1" });
|
|
164
|
+
|
|
165
|
+
if (html.includes("Вход по паролю")) {
|
|
166
|
+
throw WebClientError.authenticationRequired();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const $ = this.parseHtml(html);
|
|
170
|
+
|
|
171
|
+
// Извлекаем ID аккаунта
|
|
172
|
+
const accountIdMatch = html.match(/ID аккаунта: (\d+)/);
|
|
173
|
+
if (!accountIdMatch) {
|
|
174
|
+
throw WebClientError.couldNotParseHtml();
|
|
175
|
+
}
|
|
176
|
+
const id = Number.parseInt(accountIdMatch[1], 10);
|
|
177
|
+
|
|
178
|
+
// Извлекаем имя
|
|
179
|
+
const name = $("content .m-small-title").first().text().trim();
|
|
180
|
+
if (!name) {
|
|
181
|
+
throw WebClientError.couldNotParseHtml();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Извлекаем аватар
|
|
185
|
+
const avatarSrc = $("content .card-image.hide-on-small-and-down img")
|
|
186
|
+
.first()
|
|
187
|
+
.attr("src");
|
|
188
|
+
if (!avatarSrc) {
|
|
189
|
+
throw WebClientError.couldNotParseHtml();
|
|
190
|
+
}
|
|
191
|
+
const avatarUrl = new URL(avatarSrc, this.baseUrl).toString();
|
|
192
|
+
|
|
193
|
+
return { id, name, avatarUrl };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// === Personal Episodes ===
|
|
197
|
+
|
|
198
|
+
async getPersonalEpisodes(page: number): Promise<NewPersonalEpisode[]> {
|
|
199
|
+
const queryItems: Record<string, string> = {
|
|
200
|
+
ajax: "m-index-personal-episodes",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (page > 1) {
|
|
204
|
+
queryItems.pageP = String(page);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const html = await this.sendRequest("/", queryItems);
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
html.includes("Вход или регистрация") ||
|
|
211
|
+
html.includes("Вход - Anime 365")
|
|
212
|
+
) {
|
|
213
|
+
throw WebClientError.authenticationRequired();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const $ = this.parseHtml(html);
|
|
217
|
+
const episodes: NewPersonalEpisode[] = [];
|
|
218
|
+
|
|
219
|
+
$("#m-index-personal-episodes div.m-new-episode").each((_, element) => {
|
|
220
|
+
try {
|
|
221
|
+
const episode = this.parsePersonalEpisode($, element);
|
|
222
|
+
if (episode) {
|
|
223
|
+
episodes.push(episode);
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// Skip invalid episodes
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return episodes;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private parsePersonalEpisode(
|
|
234
|
+
$: cheerio.CheerioAPI,
|
|
235
|
+
element: Element,
|
|
236
|
+
): NewPersonalEpisode | null {
|
|
237
|
+
const $el = $(element);
|
|
238
|
+
|
|
239
|
+
// Извлекаем URL эпизода
|
|
240
|
+
const episodeHref = $el.find("a[href]").first().attr("href");
|
|
241
|
+
if (!episodeHref) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const episodeUrl = new URL(episodeHref, this.baseUrl);
|
|
246
|
+
const { seriesId, episodeId } = extractIdentifiersFromUrl(episodeUrl);
|
|
247
|
+
|
|
248
|
+
if (seriesId === null || episodeId === null) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Извлекаем постер
|
|
253
|
+
const styleAttr = $el.find("div.circle[style]").first().attr("style") || "";
|
|
254
|
+
const posterMatch = styleAttr.match(/background-image: ?url\('(.+?)'\)/);
|
|
255
|
+
if (!posterMatch) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const seriesPosterUrl = new URL(
|
|
259
|
+
posterMatch[1].replace(".140x140.1", ""),
|
|
260
|
+
this.baseUrl,
|
|
261
|
+
).toString();
|
|
262
|
+
|
|
263
|
+
// Извлекаем названия
|
|
264
|
+
const seriesTitleRu = $el.find("h5.line-1 a").first().text().trim();
|
|
265
|
+
const seriesTitleRomaji = $el.find("h6.line-2 a").first().text().trim();
|
|
266
|
+
|
|
267
|
+
if (!seriesTitleRu || !seriesTitleRomaji) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Извлекаем номер эпизода
|
|
272
|
+
const episodeNumberLabel = $el.find("span.online-h").first().text().trim();
|
|
273
|
+
if (!episodeNumberLabel) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Извлекаем тип обновления
|
|
278
|
+
const titleText = $el.find("span.title").first().text();
|
|
279
|
+
const updateTypeMatch = titleText.match(/\((.+?)\)/);
|
|
280
|
+
if (!updateTypeMatch) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const episodeUpdateType = updateTypeMatch[1].trim();
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
seriesId,
|
|
287
|
+
seriesPosterUrl,
|
|
288
|
+
seriesTitleRu,
|
|
289
|
+
seriesTitleRomaji,
|
|
290
|
+
episodeId,
|
|
291
|
+
episodeNumberLabel,
|
|
292
|
+
episodeUpdateType,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// === Recent Episodes ===
|
|
297
|
+
|
|
298
|
+
async getRecentEpisodes(page: number): Promise<NewRecentEpisode[]> {
|
|
299
|
+
const html = await this.sendRequest(`/page/${page}`, {
|
|
300
|
+
ajax: "m-index-recent-episodes",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
html.includes("Вход или регистрация") ||
|
|
305
|
+
html.includes("Вход - Anime 365")
|
|
306
|
+
) {
|
|
307
|
+
throw WebClientError.authenticationRequired();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const $ = this.parseHtml(html);
|
|
311
|
+
const episodes: NewRecentEpisode[] = [];
|
|
312
|
+
|
|
313
|
+
$("#m-index-recent-episodes .m-new-episodes.collection.with-header").each(
|
|
314
|
+
(_, sectionElement) => {
|
|
315
|
+
const $section = $(sectionElement);
|
|
316
|
+
const sectionHeader = $section.find("h3").text().trim();
|
|
317
|
+
|
|
318
|
+
if (!sectionHeader) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
$section.find("div.m-new-episode").each((_, episodeElement) => {
|
|
323
|
+
try {
|
|
324
|
+
const episode = this.parseRecentEpisode(
|
|
325
|
+
$,
|
|
326
|
+
episodeElement,
|
|
327
|
+
sectionHeader,
|
|
328
|
+
);
|
|
329
|
+
if (episode) {
|
|
330
|
+
episodes.push(episode);
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// Skip invalid episodes
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
return episodes;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private parseRecentEpisode(
|
|
343
|
+
$: cheerio.CheerioAPI,
|
|
344
|
+
element: Element,
|
|
345
|
+
sectionTitle: string,
|
|
346
|
+
): NewRecentEpisode | null {
|
|
347
|
+
const $el = $(element);
|
|
348
|
+
|
|
349
|
+
// Извлекаем URL эпизода
|
|
350
|
+
const episodeHref = $el.find("a[href]").first().attr("href");
|
|
351
|
+
if (!episodeHref) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const episodeUrl = new URL(episodeHref, this.baseUrl);
|
|
356
|
+
const { seriesId, episodeId } = extractIdentifiersFromUrl(episodeUrl);
|
|
357
|
+
|
|
358
|
+
if (seriesId === null || episodeId === null) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Извлекаем постер
|
|
363
|
+
const styleAttr = $el.find("div.circle[style]").first().attr("style") || "";
|
|
364
|
+
const posterMatch = styleAttr.match(/background-image: ?url\('(.+?)'\)/);
|
|
365
|
+
if (!posterMatch) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const seriesPosterUrl = new URL(
|
|
369
|
+
posterMatch[1].replace(".140x140.1", ""),
|
|
370
|
+
this.baseUrl,
|
|
371
|
+
).toString();
|
|
372
|
+
|
|
373
|
+
// Извлекаем названия
|
|
374
|
+
const seriesTitleRu = $el.find("h5.line-1 a").first().text().trim();
|
|
375
|
+
const seriesTitleRomaji = $el.find("h6.line-2 a").first().text().trim();
|
|
376
|
+
|
|
377
|
+
if (!seriesTitleRu || !seriesTitleRomaji) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Извлекаем номер эпизода
|
|
382
|
+
const episodeNumberLabel = $el.find("span.online-h").first().text().trim();
|
|
383
|
+
if (!episodeNumberLabel) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Извлекаем время загрузки
|
|
388
|
+
const titleText = $el.find("span.title").first().text();
|
|
389
|
+
const timeMatch = titleText.match(/в (\d{2}:\d{2})/);
|
|
390
|
+
if (!timeMatch) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Парсим дату из заголовка секции
|
|
395
|
+
const dateString = sectionTitle.replace("Новые серии ", "");
|
|
396
|
+
const episodeUploadedAt = parseWebDate(`${dateString} ${timeMatch[1]}`);
|
|
397
|
+
|
|
398
|
+
if (!episodeUploadedAt) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
seriesId,
|
|
404
|
+
seriesPosterUrl,
|
|
405
|
+
seriesTitleRu,
|
|
406
|
+
seriesTitleRomaji,
|
|
407
|
+
episodeId,
|
|
408
|
+
episodeNumberLabel,
|
|
409
|
+
episodeUploadedAt,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// === Anime List ===
|
|
414
|
+
|
|
415
|
+
async getAnimeList(
|
|
416
|
+
userId: number,
|
|
417
|
+
category: AnimeListCategory,
|
|
418
|
+
): Promise<AnimeListEntry[]> {
|
|
419
|
+
const html = await this.sendRequest(
|
|
420
|
+
`/users/${userId}/list/${AnimeListCategoryWebPath[category]}`,
|
|
421
|
+
{
|
|
422
|
+
dynpage: "1",
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
if (
|
|
427
|
+
html.includes("Вход или регистрация") ||
|
|
428
|
+
html.includes("Вход - Anime 365")
|
|
429
|
+
) {
|
|
430
|
+
throw WebClientError.authenticationRequired();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const $ = this.parseHtml(html);
|
|
434
|
+
const entries: AnimeListEntry[] = [];
|
|
435
|
+
|
|
436
|
+
$("div.card.m-animelist-card tr.m-animelist-item").each((_, element) => {
|
|
437
|
+
try {
|
|
438
|
+
const entry = this.parseAnimeListEntry($, element);
|
|
439
|
+
if (entry) {
|
|
440
|
+
entries.push(entry);
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// Skip invalid entries
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return entries;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private parseAnimeListEntry(
|
|
451
|
+
$: cheerio.CheerioAPI,
|
|
452
|
+
element: Element,
|
|
453
|
+
): AnimeListEntry | null {
|
|
454
|
+
const $el = $(element);
|
|
455
|
+
|
|
456
|
+
const seriesIdStr = $el.attr("data-id");
|
|
457
|
+
if (!seriesIdStr) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const seriesId = Number.parseInt(seriesIdStr, 10);
|
|
461
|
+
|
|
462
|
+
const seriesTitleFull = $el.find("a[href]").first().text().trim();
|
|
463
|
+
if (!seriesTitleFull) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const episodesString = $el
|
|
468
|
+
.find('td[data-name="episodes"]')
|
|
469
|
+
.first()
|
|
470
|
+
.text()
|
|
471
|
+
.trim();
|
|
472
|
+
const episodesParts = episodesString.split(" / ");
|
|
473
|
+
if (episodesParts.length !== 2) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const episodesWatched = Number.parseInt(episodesParts[0], 10);
|
|
478
|
+
if (Number.isNaN(episodesWatched)) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const episodesTotal = Number.parseInt(episodesParts[1], 10);
|
|
483
|
+
|
|
484
|
+
const scoreString = $el.find('td[data-name="score"]').first().text().trim();
|
|
485
|
+
const score = scoreString ? Number.parseInt(scoreString, 10) : null;
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
seriesId,
|
|
489
|
+
seriesTitleFull,
|
|
490
|
+
episodesWatched,
|
|
491
|
+
episodesTotal: Number.isNaN(episodesTotal) ? null : episodesTotal,
|
|
492
|
+
score: score && !Number.isNaN(score) ? score : null,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async getAnimeListEditableEntry(
|
|
497
|
+
seriesId: number,
|
|
498
|
+
): Promise<AnimeListEditableEntry> {
|
|
499
|
+
const html = await this.sendRequest(`/animelist/edit/${seriesId}`, {
|
|
500
|
+
mode: "mini",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
if (
|
|
504
|
+
html.includes("Вход или регистрация") ||
|
|
505
|
+
html.includes("Вход - Anime 365")
|
|
506
|
+
) {
|
|
507
|
+
throw WebClientError.authenticationRequired();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (html.includes("Добавить в список")) {
|
|
511
|
+
return {
|
|
512
|
+
episodesWatched: 0,
|
|
513
|
+
status: "notInList",
|
|
514
|
+
score: null,
|
|
515
|
+
commentary: null,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const $ = this.parseHtml(html);
|
|
520
|
+
|
|
521
|
+
const episodesWatchedStr = $("input#UsersRates_episodes")
|
|
522
|
+
.first()
|
|
523
|
+
.attr("value");
|
|
524
|
+
if (!episodesWatchedStr) {
|
|
525
|
+
throw WebClientError.couldNotParseHtml();
|
|
526
|
+
}
|
|
527
|
+
const episodesWatched = Number.parseInt(episodesWatchedStr, 10);
|
|
528
|
+
|
|
529
|
+
const statusStr = $("select#UsersRates_status option[selected]")
|
|
530
|
+
.first()
|
|
531
|
+
.attr("value");
|
|
532
|
+
if (!statusStr) {
|
|
533
|
+
throw WebClientError.couldNotParseHtml();
|
|
534
|
+
}
|
|
535
|
+
const statusInt = Number.parseInt(statusStr, 10);
|
|
536
|
+
const status = animeListEntryStatusFromNumericId(statusInt);
|
|
537
|
+
if (!status) {
|
|
538
|
+
throw WebClientError.couldNotParseHtml();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const scoreStr = $("#UsersRates_score option[selected]")
|
|
542
|
+
.first()
|
|
543
|
+
.attr("value");
|
|
544
|
+
const scoreInt = scoreStr ? Number.parseInt(scoreStr, 10) : 0;
|
|
545
|
+
const score = scoreInt > 0 ? scoreInt : null;
|
|
546
|
+
|
|
547
|
+
const commentary = $("#UsersRates_comment").first().text() || null;
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
episodesWatched,
|
|
551
|
+
status,
|
|
552
|
+
score,
|
|
553
|
+
commentary,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async editAnimeListEntry(
|
|
558
|
+
seriesId: number,
|
|
559
|
+
score: number,
|
|
560
|
+
episodes: number,
|
|
561
|
+
status: number,
|
|
562
|
+
comment: string,
|
|
563
|
+
): Promise<void> {
|
|
564
|
+
await this.sendPostRequest(
|
|
565
|
+
`/animelist/edit/${seriesId}`,
|
|
566
|
+
{ mode: "mini" },
|
|
567
|
+
{
|
|
568
|
+
"UsersRates[score]": String(score),
|
|
569
|
+
"UsersRates[episodes]": String(episodes),
|
|
570
|
+
"UsersRates[status]": String(status),
|
|
571
|
+
"UsersRates[comment]": comment,
|
|
572
|
+
},
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// === Mark Episode ===
|
|
577
|
+
|
|
578
|
+
async markEpisodeAsWatched(translationId: number): Promise<void> {
|
|
579
|
+
await this.sendPostRequest(
|
|
580
|
+
`/translations/watched/${translationId}`,
|
|
581
|
+
{},
|
|
582
|
+
{},
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// === Moments ===
|
|
587
|
+
|
|
588
|
+
async getMoments(
|
|
589
|
+
page: number,
|
|
590
|
+
sort?: MomentSorting,
|
|
591
|
+
): Promise<MomentPreview[]> {
|
|
592
|
+
const queryItems: Record<string, string> = {};
|
|
593
|
+
|
|
594
|
+
if (page === 1) {
|
|
595
|
+
queryItems.dynpage = "1";
|
|
596
|
+
} else {
|
|
597
|
+
queryItems.ajaxPage = "yw_moments_all";
|
|
598
|
+
queryItems.ajaxPageMode = "more";
|
|
599
|
+
queryItems["moments-page"] = String(page);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (sort) {
|
|
603
|
+
queryItems["MomentsFilter[sort]"] = sort;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const html = await this.sendRequest("/moments/index", queryItems);
|
|
607
|
+
|
|
608
|
+
if (
|
|
609
|
+
html.includes("Вход или регистрация") ||
|
|
610
|
+
html.includes("Вход - Anime 365")
|
|
611
|
+
) {
|
|
612
|
+
throw WebClientError.authenticationRequired();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const $ = this.parseHtml(html);
|
|
616
|
+
const moments: MomentPreview[] = [];
|
|
617
|
+
|
|
618
|
+
$("#yw_moments_all div.m-moment").each((_, element) => {
|
|
619
|
+
try {
|
|
620
|
+
const moment = this.parseMomentPreview($, element);
|
|
621
|
+
if (moment) {
|
|
622
|
+
moments.push(moment);
|
|
623
|
+
}
|
|
624
|
+
} catch {
|
|
625
|
+
// Skip invalid moments
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
return moments;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async getMomentsBySeries(
|
|
633
|
+
seriesId: number,
|
|
634
|
+
page: number,
|
|
635
|
+
): Promise<MomentPreview[]> {
|
|
636
|
+
const queryItems: Record<string, string> = {};
|
|
637
|
+
|
|
638
|
+
if (page === 1) {
|
|
639
|
+
queryItems.dynpage = "1";
|
|
640
|
+
} else {
|
|
641
|
+
queryItems.ajaxPage = "yw_moments_by_series";
|
|
642
|
+
queryItems.ajaxPageMode = "more";
|
|
643
|
+
queryItems["moments-page"] = String(page);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const html = await this.sendRequest(
|
|
647
|
+
`/moments/listBySeries/${seriesId}`,
|
|
648
|
+
queryItems,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
if (
|
|
652
|
+
html.includes("Вход или регистрация") ||
|
|
653
|
+
html.includes("Вход - Anime 365")
|
|
654
|
+
) {
|
|
655
|
+
throw WebClientError.authenticationRequired();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const $ = this.parseHtml(html);
|
|
659
|
+
const moments: MomentPreview[] = [];
|
|
660
|
+
|
|
661
|
+
$("#yw_moments_by_series div.m-moment").each((_, element) => {
|
|
662
|
+
try {
|
|
663
|
+
const moment = this.parseMomentPreview($, element);
|
|
664
|
+
if (moment) {
|
|
665
|
+
moments.push(moment);
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
// Skip invalid moments
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
return moments;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private parseMomentPreview(
|
|
676
|
+
$: cheerio.CheerioAPI,
|
|
677
|
+
element: Element,
|
|
678
|
+
): MomentPreview | null {
|
|
679
|
+
const $el = $(element);
|
|
680
|
+
|
|
681
|
+
const momentTitle = $el.find(".m-moment__title a").first().text().trim();
|
|
682
|
+
if (!momentTitle) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const sourceDescription = $el
|
|
687
|
+
.find(".m-moment__episode")
|
|
688
|
+
.first()
|
|
689
|
+
.text()
|
|
690
|
+
.trim();
|
|
691
|
+
if (!sourceDescription) {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const previewSrc = $el
|
|
696
|
+
.find(".m-moment__thumb.a img[src]")
|
|
697
|
+
.first()
|
|
698
|
+
.attr("src");
|
|
699
|
+
if (!previewSrc) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
const coverUrl = new URL(
|
|
703
|
+
previewSrc.trim().replace(".320x180", ".1280x720").replace(/\?.+$/, ""),
|
|
704
|
+
this.baseUrl,
|
|
705
|
+
).toString();
|
|
706
|
+
|
|
707
|
+
const momentHref = $el
|
|
708
|
+
.find(".m-moment__title a[href]")
|
|
709
|
+
.first()
|
|
710
|
+
.attr("href");
|
|
711
|
+
if (!momentHref) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
const momentIdMatch = momentHref.match(/\/moments\/(\d+)/);
|
|
715
|
+
if (!momentIdMatch) {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
const momentId = Number.parseInt(momentIdMatch[1], 10);
|
|
719
|
+
|
|
720
|
+
const durationString = $el
|
|
721
|
+
.find(".m-moment__duration")
|
|
722
|
+
.first()
|
|
723
|
+
.text()
|
|
724
|
+
.trim();
|
|
725
|
+
const durationSeconds = parseDurationString(durationString);
|
|
726
|
+
if (durationSeconds === null) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
momentId,
|
|
732
|
+
coverUrl,
|
|
733
|
+
momentTitle,
|
|
734
|
+
sourceDescription,
|
|
735
|
+
durationSeconds,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async getMomentDetails(momentId: number): Promise<MomentDetails> {
|
|
740
|
+
const html = await this.sendRequest(`/moments/${momentId}`, {});
|
|
741
|
+
|
|
742
|
+
if (
|
|
743
|
+
html.includes("Вход или регистрация") ||
|
|
744
|
+
html.includes("Вход - Anime 365")
|
|
745
|
+
) {
|
|
746
|
+
throw WebClientError.authenticationRequired();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const $ = this.parseHtml(html);
|
|
750
|
+
|
|
751
|
+
const linkElements = $(".m-moment-player h3 a[href]");
|
|
752
|
+
if (linkElements.length !== 2) {
|
|
753
|
+
throw WebClientError.couldNotParseHtml();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const episodeHref = linkElements.first().attr("href");
|
|
757
|
+
if (!episodeHref) {
|
|
758
|
+
throw WebClientError.couldNotParseHtml();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const episodeUrl = new URL(episodeHref, this.baseUrl);
|
|
762
|
+
const { seriesId, episodeId } = extractIdentifiersFromUrl(episodeUrl);
|
|
763
|
+
|
|
764
|
+
if (seriesId === null || episodeId === null) {
|
|
765
|
+
throw WebClientError.couldNotParseHtml();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const seriesTitle = linkElements.eq(1).text().trim();
|
|
769
|
+
if (!seriesTitle) {
|
|
770
|
+
throw WebClientError.couldNotParseHtml();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return { seriesId, seriesTitle, episodeId };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async getMomentEmbed(momentId: number): Promise<MomentEmbed> {
|
|
777
|
+
const html = await this.sendRequest(`/moments/embed/${momentId}`, {});
|
|
778
|
+
|
|
779
|
+
if (
|
|
780
|
+
html.includes("Вход или регистрация") ||
|
|
781
|
+
html.includes("Вход - Anime 365")
|
|
782
|
+
) {
|
|
783
|
+
throw WebClientError.authenticationRequired();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const $ = this.parseHtml(html);
|
|
787
|
+
|
|
788
|
+
const dataSourcesJson = $("#main-video").first().attr("data-sources");
|
|
789
|
+
if (!dataSourcesJson) {
|
|
790
|
+
throw WebClientError.couldNotParseHtml();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let videoSources: VideoSource[];
|
|
794
|
+
try {
|
|
795
|
+
videoSources = JSON.parse(dataSourcesJson);
|
|
796
|
+
} catch {
|
|
797
|
+
throw WebClientError.couldNotParseHtml();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const validSource = videoSources
|
|
801
|
+
.filter((s) => s.urls && s.urls.length > 0)
|
|
802
|
+
.sort((a, b) => b.height - a.height)[0];
|
|
803
|
+
|
|
804
|
+
if (!validSource || !validSource.urls[0]) {
|
|
805
|
+
throw WebClientError.couldNotParseHtml();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return { videoUrl: validSource.urls[0] };
|
|
809
|
+
}
|
|
810
|
+
}
|