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.
@@ -0,0 +1,99 @@
1
+ import { CookieJar } from "tough-cookie";
2
+
3
+ export interface RequestOptions extends Omit<RequestInit, "dispatcher"> {
4
+ timeout?: number;
5
+ }
6
+
7
+ export class HttpSession {
8
+ readonly jar: CookieJar;
9
+ readonly baseUrl: string;
10
+
11
+ constructor(baseUrl: string) {
12
+ this.baseUrl = baseUrl;
13
+ this.jar = new CookieJar();
14
+ }
15
+
16
+ async request(path: string, options: RequestOptions = {}): Promise<Response> {
17
+ const { timeout = 10000, ...init } = options;
18
+ let url = new URL(path, this.baseUrl);
19
+
20
+ const controller = new AbortController();
21
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
22
+
23
+ try {
24
+ // Manually follow redirects to preserve cookies from intermediate responses
25
+ let redirectCount = 0;
26
+ const maxRedirects = 10;
27
+
28
+ while (redirectCount < maxRedirects) {
29
+ // Get cookies for this URL
30
+ const cookieString = await this.jar.getCookieString(url.toString());
31
+
32
+ const headers = {
33
+ Accept: "*/*",
34
+ "User-Agent":
35
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
36
+ "Cache-Control": "no-cache",
37
+ ...(cookieString ? { Cookie: cookieString } : {}),
38
+ ...init.headers,
39
+ };
40
+
41
+ const response = await fetch(url, {
42
+ ...init,
43
+ headers,
44
+ signal: controller.signal,
45
+ redirect: "manual", // Don't follow redirects automatically
46
+ });
47
+
48
+ // Store cookies from response BEFORE following redirect
49
+ const setCookieHeaders = response.headers.getSetCookie();
50
+ for (const setCookie of setCookieHeaders) {
51
+ await this.jar.setCookie(setCookie, url.toString());
52
+ }
53
+
54
+ // Check if it's a redirect
55
+ if (response.status >= 300 && response.status < 400) {
56
+ const location = response.headers.get("location");
57
+ if (!location) {
58
+ return response;
59
+ }
60
+
61
+ // Resolve relative URL against current URL
62
+ url = new URL(location, url);
63
+ redirectCount++;
64
+
65
+ // For 302/303, switch to GET method
66
+ if (response.status === 302 || response.status === 303) {
67
+ init.method = "GET";
68
+ init.body = undefined;
69
+ }
70
+
71
+ continue;
72
+ }
73
+
74
+ return response;
75
+ }
76
+
77
+ throw new Error("Too many redirects");
78
+ } finally {
79
+ clearTimeout(timeoutId);
80
+ }
81
+ }
82
+
83
+ async getCookie(name: string): Promise<string | undefined> {
84
+ const cookies = await this.jar.getCookies(this.baseUrl);
85
+ return cookies.find((c) => c.key === name)?.value;
86
+ }
87
+
88
+ async getCookieString(): Promise<string> {
89
+ return await this.jar.getCookieString(this.baseUrl);
90
+ }
91
+
92
+ async setCookie(name: string, value: string): Promise<void> {
93
+ const url = new URL(this.baseUrl);
94
+ await this.jar.setCookie(
95
+ `${name}=${value}; Domain=${url.hostname}; Path=/`,
96
+ this.baseUrl,
97
+ );
98
+ }
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Core
2
+
3
+ // API Client
4
+ export {
5
+ ApiClient,
6
+ type ListEpisodesOptions,
7
+ type ListSeriesOptions,
8
+ } from "./api/api-client.js";
9
+ export { isEmptyDate, parseApiDate } from "./api/date-decoder.js";
10
+ export { ApiClientError, ApiError } from "./api/errors.js";
11
+ // API Types
12
+ export type {
13
+ Episode,
14
+ EpisodeFull,
15
+ Series,
16
+ SeriesFull,
17
+ SeriesFullDescription,
18
+ SeriesFullGenre,
19
+ SeriesType,
20
+ Titles,
21
+ Translation,
22
+ TranslationEmbed,
23
+ TranslationEmbedStream,
24
+ TranslationFull,
25
+ } from "./api/types/index.js";
26
+ export { HttpSession, type RequestOptions } from "./http-session.js";
27
+ export { WebClientError, WebClientTypeNormalizationError } from "./web/errors.js";
28
+ export { extractIdentifiersFromUrl, parseDurationString, parseWebDate } from "./web/helpers.js";
29
+ // Web Types
30
+ export type {
31
+ AnimeListCategory,
32
+ AnimeListEditableEntry,
33
+ AnimeListEntry,
34
+ AnimeListEntryStatus,
35
+ EditAnimeListResult,
36
+ MomentDetails,
37
+ MomentEmbed,
38
+ MomentPreview,
39
+ MomentSorting,
40
+ NewPersonalEpisode,
41
+ NewRecentEpisode,
42
+ Profile,
43
+ VideoSource,
44
+ } from "./web/types/index.js";
45
+ export {
46
+ AnimeListCategoryNumericId,
47
+ AnimeListCategoryWebPath,
48
+ AnimeListEntryStatusNumericId,
49
+ animeListCategoryFromNumericId,
50
+ animeListEntryStatusFromNumericId,
51
+ } from "./web/types/index.js";
52
+ // Web Client
53
+ export { WebClient } from "./web/web-client.js";
@@ -0,0 +1,43 @@
1
+ export class WebClientError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = "WebClientError";
5
+ }
6
+
7
+ static couldNotConvertResponseToHttpResponse(): WebClientError {
8
+ return new WebClientError("Could not convert response to HTTP response");
9
+ }
10
+
11
+ static couldNotConvertResponseDataToString(): WebClientError {
12
+ return new WebClientError("Could not convert response data to string");
13
+ }
14
+
15
+ static badStatusCode(statusCode: number): WebClientError {
16
+ return new WebClientError(`Bad status code: ${statusCode}`);
17
+ }
18
+
19
+ static authenticationRequired(): WebClientError {
20
+ return new WebClientError("Authentication required");
21
+ }
22
+
23
+ static couldNotParseHtml(): WebClientError {
24
+ return new WebClientError("Could not parse HTML");
25
+ }
26
+
27
+ static unknownError(error: Error): WebClientError {
28
+ const err = new WebClientError(`Unknown error: ${error.message}`);
29
+ err.cause = error;
30
+ return err;
31
+ }
32
+ }
33
+
34
+ export class WebClientTypeNormalizationError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = "WebClientTypeNormalizationError";
38
+ }
39
+
40
+ static failedCreatingDTOFromHTMLElement(message: string): WebClientTypeNormalizationError {
41
+ return new WebClientTypeNormalizationError(message);
42
+ }
43
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Извлекает ID сериала и эпизода из URL вида /catalog/{seriesId}-{slug}/{episodeId}-{slug}
3
+ */
4
+ export function extractIdentifiersFromUrl(url: URL): {
5
+ seriesId: number | null;
6
+ episodeId: number | null;
7
+ } {
8
+ const pathParts = url.pathname.split("/").filter(Boolean);
9
+
10
+ if (pathParts.length < 2 || pathParts[0] !== "catalog") {
11
+ return { seriesId: null, episodeId: null };
12
+ }
13
+
14
+ let seriesId: number | null = null;
15
+ let episodeId: number | null = null;
16
+
17
+ // Извлекаем seriesId из второго сегмента (например, "12345-anime-name")
18
+ const seriesMatch = pathParts[1]?.match(/(\d+)$/);
19
+ if (seriesMatch) {
20
+ seriesId = Number.parseInt(seriesMatch[1], 10);
21
+ }
22
+
23
+ // Извлекаем episodeId из третьего сегмента (например, "67890-episode-1")
24
+ if (pathParts.length >= 3) {
25
+ const episodeMatch = pathParts[2]?.match(/(\d+)$/);
26
+ if (episodeMatch) {
27
+ episodeId = Number.parseInt(episodeMatch[1], 10);
28
+ }
29
+ }
30
+
31
+ return { seriesId, episodeId };
32
+ }
33
+
34
+ /**
35
+ * Парсит строку длительности формата "mm:ss" или "hh:mm:ss" в секунды.
36
+ */
37
+ export function parseDurationString(durationString: string): number | null {
38
+ const parts = durationString.split(":").map((p) => Number.parseInt(p, 10));
39
+
40
+ if (parts.some((p) => Number.isNaN(p))) {
41
+ return null;
42
+ }
43
+
44
+ switch (parts.length) {
45
+ case 2: // mm:ss
46
+ return parts[0] * 60 + parts[1];
47
+ case 3: // hh:mm:ss
48
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
49
+ default:
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Парсит дату в формате "dd.MM.yyyy HH:mm" в московской таймзоне.
56
+ */
57
+ export function parseWebDate(dateString: string): Date | null {
58
+ // Формат: "15.01.2024 14:30"
59
+ const match = dateString.match(/^(\d{2})\.(\d{2})\.(\d{4}) (\d{2}):(\d{2})$/);
60
+ if (!match) {
61
+ return null;
62
+ }
63
+
64
+ const [, day, month, year, hours, minutes] = match;
65
+ // Создаем ISO строку с московским смещением (+03:00)
66
+ const isoString = `${year}-${month}-${day}T${hours}:${minutes}:00+03:00`;
67
+ const date = new Date(isoString);
68
+
69
+ return Number.isNaN(date.getTime()) ? null : date;
70
+ }
@@ -0,0 +1,4 @@
1
+ export { WebClientError, WebClientTypeNormalizationError } from "./errors.js";
2
+ export { extractIdentifiersFromUrl, parseDurationString, parseWebDate } from "./helpers.js";
3
+ export * from "./types/index.js";
4
+ export { WebClient } from "./web-client.js";
@@ -0,0 +1,70 @@
1
+ export type AnimeListEntryStatus =
2
+ | "watching"
3
+ | "completed"
4
+ | "onHold"
5
+ | "dropped"
6
+ | "planned"
7
+ | "notInList";
8
+
9
+ export const AnimeListEntryStatusNumericId: Record<AnimeListEntryStatus, number> = {
10
+ planned: 0,
11
+ watching: 1,
12
+ completed: 2,
13
+ onHold: 3,
14
+ dropped: 4,
15
+ notInList: 99,
16
+ };
17
+
18
+ export function animeListEntryStatusFromNumericId(id: number): AnimeListEntryStatus | null {
19
+ switch (id) {
20
+ case 0:
21
+ return "planned";
22
+ case 1:
23
+ return "watching";
24
+ case 2:
25
+ return "completed";
26
+ case 3:
27
+ return "onHold";
28
+ case 4:
29
+ return "dropped";
30
+ case 99:
31
+ return "notInList";
32
+ default:
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export type AnimeListCategory = "watching" | "completed" | "onHold" | "dropped" | "planned";
38
+
39
+ export const AnimeListCategoryWebPath: Record<AnimeListCategory, string> = {
40
+ watching: "watching",
41
+ completed: "completed",
42
+ onHold: "onhold",
43
+ dropped: "dropped",
44
+ planned: "planned",
45
+ };
46
+
47
+ export const AnimeListCategoryNumericId: Record<AnimeListCategory, number> = {
48
+ planned: 0,
49
+ watching: 1,
50
+ completed: 2,
51
+ onHold: 3,
52
+ dropped: 4,
53
+ };
54
+
55
+ export function animeListCategoryFromNumericId(id: number): AnimeListCategory | null {
56
+ switch (id) {
57
+ case 0:
58
+ return "planned";
59
+ case 1:
60
+ return "watching";
61
+ case 2:
62
+ return "completed";
63
+ case 3:
64
+ return "onHold";
65
+ case 4:
66
+ return "dropped";
67
+ default:
68
+ return null;
69
+ }
70
+ }
@@ -0,0 +1,23 @@
1
+ import type { AnimeListEntryStatus } from "./anime-list-status.js";
2
+
3
+ export interface AnimeListEntry {
4
+ seriesId: number;
5
+ seriesTitleFull: string;
6
+ episodesWatched: number;
7
+ episodesTotal: number | null;
8
+ score: number | null;
9
+ }
10
+
11
+ export interface AnimeListEditableEntry {
12
+ episodesWatched: number;
13
+ status: AnimeListEntryStatus;
14
+ score: number | null;
15
+ commentary: string | null;
16
+ }
17
+
18
+ export interface EditAnimeListResult {
19
+ id: number;
20
+ status: string;
21
+ score: string;
22
+ episodes: string;
23
+ }
@@ -0,0 +1,21 @@
1
+ export type { AnimeListEditableEntry, AnimeListEntry, EditAnimeListResult } from "./anime-list.js";
2
+ export type {
3
+ AnimeListCategory,
4
+ AnimeListEntryStatus,
5
+ } from "./anime-list-status.js";
6
+ export {
7
+ AnimeListCategoryNumericId,
8
+ AnimeListCategoryWebPath,
9
+ AnimeListEntryStatusNumericId,
10
+ animeListCategoryFromNumericId,
11
+ animeListEntryStatusFromNumericId,
12
+ } from "./anime-list-status.js";
13
+ export type {
14
+ MomentDetails,
15
+ MomentEmbed,
16
+ MomentPreview,
17
+ MomentSorting,
18
+ VideoSource,
19
+ } from "./moment.js";
20
+ export type { NewPersonalEpisode, NewRecentEpisode } from "./personal-episode.js";
21
+ export type { Profile } from "./profile.js";
@@ -0,0 +1,25 @@
1
+ export interface MomentPreview {
2
+ momentId: number;
3
+ coverUrl: string;
4
+ momentTitle: string;
5
+ sourceDescription: string;
6
+ /** Duration in seconds */
7
+ durationSeconds: number;
8
+ }
9
+
10
+ export interface MomentDetails {
11
+ seriesId: number;
12
+ seriesTitle: string;
13
+ episodeId: number;
14
+ }
15
+
16
+ export interface VideoSource {
17
+ height: number;
18
+ urls: string[];
19
+ }
20
+
21
+ export interface MomentEmbed {
22
+ videoUrl: string;
23
+ }
24
+
25
+ export type MomentSorting = "new" | "old" | "popular";
@@ -0,0 +1,19 @@
1
+ export interface NewPersonalEpisode {
2
+ seriesId: number;
3
+ seriesPosterUrl: string;
4
+ seriesTitleRu: string;
5
+ seriesTitleRomaji: string;
6
+ episodeId: number;
7
+ episodeNumberLabel: string;
8
+ episodeUpdateType: string;
9
+ }
10
+
11
+ export interface NewRecentEpisode {
12
+ seriesId: number;
13
+ seriesPosterUrl: string;
14
+ seriesTitleRu: string;
15
+ seriesTitleRomaji: string;
16
+ episodeId: number;
17
+ episodeNumberLabel: string;
18
+ episodeUploadedAt: Date;
19
+ }
@@ -0,0 +1,5 @@
1
+ export interface Profile {
2
+ id: number;
3
+ name: string;
4
+ avatarUrl: string;
5
+ }