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 ADDED
@@ -0,0 +1,105 @@
1
+ # ichime-ts-api-client
2
+
3
+ TypeScript API client для работы с сайтом [smotret-anime.com](https://smotret-anime.com).
4
+
5
+ ## Установка
6
+
7
+ ```bash
8
+ pnpm add ichime-ts-api-client
9
+ ```
10
+
11
+ ## Использование
12
+
13
+ ```typescript
14
+ import { ApiClient } from "ichime-ts-api-client";
15
+
16
+ const client = new ApiClient();
17
+
18
+ // Получить список серий
19
+ const series = await client.listSeries({
20
+ query: "naruto",
21
+ limit: 10,
22
+ offset: 0,
23
+ });
24
+
25
+ console.log(series);
26
+ ```
27
+
28
+ ## API
29
+
30
+ ### ApiClient
31
+
32
+ Основной класс для работы с API.
33
+
34
+ #### Конструктор
35
+
36
+ ```typescript
37
+ new ApiClient(options?: ApiClientOptions)
38
+ ```
39
+
40
+ **Параметры:**
41
+ - `options.baseURL` - базовый URL API (по умолчанию: `https://smotret-anime.com`)
42
+ - `options.timeout` - таймаут запросов в миллисекундах (по умолчанию: `10000`)
43
+
44
+ #### Методы
45
+
46
+ ##### `listSeries(options?: ListSeriesOptions): Promise<Series[]>`
47
+
48
+ Получить список серий.
49
+
50
+ **Параметры:**
51
+ - `options.query` - поисковый запрос
52
+ - `options.limit` - количество результатов
53
+ - `options.offset` - смещение для пагинации
54
+ - `options.chips` - фильтры в виде объекта ключ-значение
55
+ - `options.myAnimeListId` - ID из MyAnimeList
56
+
57
+ **Пример:**
58
+
59
+ ```typescript
60
+ const series = await client.listSeries({
61
+ query: "attack on titan",
62
+ limit: 20,
63
+ });
64
+ ```
65
+
66
+ ## Разработка
67
+
68
+ ### Требования
69
+
70
+ - Node.js >= 24.0.0
71
+ - pnpm
72
+
73
+ ### Установка зависимостей
74
+
75
+ ```bash
76
+ pnpm install
77
+ ```
78
+
79
+ ### Сборка
80
+
81
+ ```bash
82
+ pnpm build
83
+ ```
84
+
85
+ ### Тестирование
86
+
87
+ ```bash
88
+ pnpm test
89
+ ```
90
+
91
+ ### Линтинг
92
+
93
+ ```bash
94
+ pnpm lint
95
+ ```
96
+
97
+ ### Форматирование
98
+
99
+ ```bash
100
+ pnpm format
101
+ ```
102
+
103
+ ## Лицензия
104
+
105
+ MIT
package/biome.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false,
10
+ "includes": ["**", "!**/node_modules", "!**/dist", "!**/.build", "!**/*.swift"]
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "space",
15
+ "indentWidth": 2,
16
+ "lineWidth": 100
17
+ },
18
+ "assist": {
19
+ "actions": {
20
+ "source": {
21
+ "organizeImports": "on"
22
+ }
23
+ }
24
+ },
25
+ "linter": {
26
+ "enabled": true,
27
+ "rules": {
28
+ "recommended": true,
29
+ "complexity": {
30
+ "noBannedTypes": "error",
31
+ "noUselessTypeConstraint": "error"
32
+ },
33
+ "correctness": {
34
+ "noUnusedVariables": "error"
35
+ },
36
+ "style": {
37
+ "useConst": "error",
38
+ "useImportType": "error"
39
+ },
40
+ "suspicious": {
41
+ "noExplicitAny": "warn"
42
+ }
43
+ }
44
+ },
45
+ "javascript": {
46
+ "formatter": {
47
+ "quoteStyle": "double",
48
+ "semicolons": "always",
49
+ "trailingCommas": "es5"
50
+ }
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "ichime-ts-api-client",
3
+ "version": "1.0.0",
4
+ "description": "TypeScript API client for smotret-anime.com",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "engines": {
15
+ "node": ">=24.0.0"
16
+ },
17
+ "scripts": {
18
+ "build": "tsdown",
19
+ "test": "vitest",
20
+ "test:coverage": "vitest --coverage",
21
+ "lint": "biome check .",
22
+ "lint:fix": "biome check --write .",
23
+ "format": "biome format --write .",
24
+ "typecheck": "tsc --noEmit",
25
+ "prepublish": "pnpm build"
26
+ },
27
+ "keywords": [
28
+ "anime",
29
+ "api",
30
+ "client",
31
+ "smotret-anime"
32
+ ],
33
+ "author": "",
34
+ "license": "MIT",
35
+ "devDependencies": {
36
+ "@biomejs/biome": "^2.3.13",
37
+ "@types/node": "^25.1.0",
38
+ "@types/tough-cookie": "^4.0.5",
39
+ "tsdown": "^0.20.1",
40
+ "typescript": "^5.9.3",
41
+ "vitest": "^4.0.18"
42
+ },
43
+ "dependencies": {
44
+ "cheerio": "^1.2.0",
45
+ "domhandler": "5.0.3",
46
+ "dotenv": "^17.2.3",
47
+ "tough-cookie": "^6.0.0"
48
+ }
49
+ }
@@ -0,0 +1,173 @@
1
+ import type { HttpSession } from "../http-session.js";
2
+ import { transformDatesInResponse } from "./date-decoder.js";
3
+ import { ApiClientError, ApiError } from "./errors.js";
4
+ import type {
5
+ Episode,
6
+ EpisodeFull,
7
+ Series,
8
+ SeriesFull,
9
+ TranslationEmbed,
10
+ TranslationFull,
11
+ } from "./types/index.js";
12
+
13
+ interface ApiSuccessfulResponse<T> {
14
+ data: T;
15
+ }
16
+
17
+ interface ApiErrorResponse {
18
+ error: {
19
+ code: number;
20
+ message: string;
21
+ };
22
+ }
23
+
24
+ export interface ListSeriesOptions {
25
+ query?: string;
26
+ limit?: number;
27
+ offset?: number;
28
+ chips?: Record<string, string>;
29
+ myAnimeListId?: number;
30
+ }
31
+
32
+ export interface ListEpisodesOptions {
33
+ seriesId?: number;
34
+ limit?: number;
35
+ offset?: number;
36
+ }
37
+
38
+ export class ApiClient {
39
+ constructor(private readonly session: HttpSession) {}
40
+
41
+ async sendRequest<T>(endpoint: string, queryItems: Record<string, string> = {}): Promise<T> {
42
+ const url = new URL(`/api${endpoint}`, this.session.baseUrl);
43
+
44
+ // Сортируем параметры по имени (как в Swift)
45
+ const sortedParams = Object.entries(queryItems).sort(([a], [b]) => a.localeCompare(b));
46
+ for (const [key, value] of sortedParams) {
47
+ url.searchParams.set(key, value);
48
+ }
49
+
50
+ let response: Response;
51
+ try {
52
+ response = await this.session.request(url.pathname + url.search, {
53
+ method: "GET",
54
+ headers: {
55
+ Accept: "application/json",
56
+ },
57
+ });
58
+ } catch (error) {
59
+ throw ApiClientError.requestFailed(error instanceof Error ? error : undefined);
60
+ }
61
+
62
+ let data: unknown;
63
+ try {
64
+ data = await response.json();
65
+ } catch (error) {
66
+ throw ApiClientError.canNotDecodeResponseJson(error instanceof Error ? error : undefined);
67
+ }
68
+
69
+ // Проверяем на ошибку API
70
+ if (this.isApiErrorResponse(data)) {
71
+ const apiError = data.error;
72
+
73
+ if (apiError.code === 403) {
74
+ throw ApiClientError.apiError(ApiError.authenticationRequired());
75
+ }
76
+
77
+ if (apiError.code === 404) {
78
+ throw ApiClientError.apiError(ApiError.notFound());
79
+ }
80
+
81
+ throw ApiClientError.apiError(ApiError.other(apiError.code, apiError.message));
82
+ }
83
+
84
+ // Успешный ответ
85
+ if (this.isApiSuccessfulResponse<T>(data)) {
86
+ return transformDatesInResponse<T>(data.data);
87
+ }
88
+
89
+ throw ApiClientError.canNotDecodeResponseJson();
90
+ }
91
+
92
+ private isApiErrorResponse(data: unknown): data is ApiErrorResponse {
93
+ return (
94
+ typeof data === "object" &&
95
+ data !== null &&
96
+ "error" in data &&
97
+ typeof (data as ApiErrorResponse).error === "object" &&
98
+ (data as ApiErrorResponse).error !== null &&
99
+ "code" in (data as ApiErrorResponse).error &&
100
+ "message" in (data as ApiErrorResponse).error
101
+ );
102
+ }
103
+
104
+ private isApiSuccessfulResponse<T>(data: unknown): data is ApiSuccessfulResponse<T> {
105
+ return typeof data === "object" && data !== null && "data" in data;
106
+ }
107
+
108
+ // === API Requests ===
109
+
110
+ async getSeries(seriesId: number): Promise<SeriesFull> {
111
+ return this.sendRequest<SeriesFull>(`/series/${seriesId}`);
112
+ }
113
+
114
+ async getEpisode(episodeId: number): Promise<EpisodeFull> {
115
+ return this.sendRequest<EpisodeFull>(`/episodes/${episodeId}`);
116
+ }
117
+
118
+ async listSeries(options: ListSeriesOptions = {}): Promise<Series[]> {
119
+ const queryItems: Record<string, string> = {};
120
+
121
+ if (options.chips) {
122
+ const chipsValue = Object.entries(options.chips)
123
+ .sort(([a], [b]) => a.localeCompare(b))
124
+ .map(([key, value]) => `${key}=${value}`)
125
+ .join(";");
126
+ queryItems.chips = chipsValue;
127
+ }
128
+
129
+ if (options.query !== undefined) {
130
+ queryItems.query = options.query;
131
+ }
132
+
133
+ if (options.limit !== undefined) {
134
+ queryItems.limit = String(options.limit);
135
+ }
136
+
137
+ if (options.offset !== undefined) {
138
+ queryItems.offset = String(options.offset);
139
+ }
140
+
141
+ if (options.myAnimeListId !== undefined) {
142
+ queryItems.myAnimeListId = String(options.myAnimeListId);
143
+ }
144
+
145
+ return this.sendRequest<Series[]>("/series", queryItems);
146
+ }
147
+
148
+ async listEpisodes(options: ListEpisodesOptions = {}): Promise<Episode[]> {
149
+ const queryItems: Record<string, string> = {};
150
+
151
+ if (options.seriesId !== undefined) {
152
+ queryItems.seriesId = String(options.seriesId);
153
+ }
154
+
155
+ if (options.limit !== undefined) {
156
+ queryItems.limit = String(options.limit);
157
+ }
158
+
159
+ if (options.offset !== undefined) {
160
+ queryItems.offset = String(options.offset);
161
+ }
162
+
163
+ return this.sendRequest<Episode[]>("/episodes", queryItems);
164
+ }
165
+
166
+ async getTranslation(translationId: number): Promise<TranslationFull> {
167
+ return this.sendRequest<TranslationFull>(`/translations/${translationId}`);
168
+ }
169
+
170
+ async getTranslationEmbed(translationId: number): Promise<TranslationEmbed> {
171
+ return this.sendRequest<TranslationEmbed>(`/translations/embed/${translationId}`);
172
+ }
173
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Парсит даты в формате API Anime365: "yyyy-MM-dd HH:mm:ss" в московской таймзоне.
3
+ */
4
+ export function parseApiDate(dateString: string): Date {
5
+ // API возвращает даты в формате "2024-01-15 14:30:00" в московской таймзоне (UTC+3)
6
+ const [datePart, timePart] = dateString.split(" ");
7
+ if (!datePart || !timePart) {
8
+ throw new Error(`Invalid date format: ${dateString}`);
9
+ }
10
+
11
+ // Добавляем смещение московской таймзоны (+03:00)
12
+ const isoString = `${datePart}T${timePart}+03:00`;
13
+ const date = new Date(isoString);
14
+
15
+ if (Number.isNaN(date.getTime())) {
16
+ throw new Error(`Invalid date: ${dateString}`);
17
+ }
18
+
19
+ return date;
20
+ }
21
+
22
+ /**
23
+ * Проверяет, является ли дата "пустой" датой-заглушкой API (2000-01-01 00:00:00).
24
+ */
25
+ export function isEmptyDate(date: Date): boolean {
26
+ // API возвращает "2000-01-01 00:00:00" MSK как пустую дату
27
+ // В UTC это будет 1999-12-31 21:00:00
28
+ return date.getTime() === new Date("1999-12-31T21:00:00.000Z").getTime();
29
+ }
30
+
31
+ /**
32
+ * Рекурсивно преобразует строки дат в объекты Date в JSON-ответе.
33
+ * Ищет поля, заканчивающиеся на "DateTime".
34
+ */
35
+ export function transformDatesInResponse<T>(obj: unknown): T {
36
+ if (obj === null || obj === undefined) {
37
+ return obj as T;
38
+ }
39
+
40
+ if (Array.isArray(obj)) {
41
+ return obj.map((item) => transformDatesInResponse(item)) as T;
42
+ }
43
+
44
+ if (typeof obj === "object") {
45
+ const result: Record<string, unknown> = {};
46
+ for (const [key, value] of Object.entries(obj)) {
47
+ if (key.endsWith("DateTime") && typeof value === "string") {
48
+ result[key] = parseApiDate(value);
49
+ } else {
50
+ result[key] = transformDatesInResponse(value);
51
+ }
52
+ }
53
+ return result as T;
54
+ }
55
+
56
+ return obj as T;
57
+ }
@@ -0,0 +1,43 @@
1
+ export class ApiError extends Error {
2
+ constructor(
3
+ public readonly code: number,
4
+ message: string
5
+ ) {
6
+ super(message);
7
+ this.name = "ApiError";
8
+ }
9
+
10
+ static authenticationRequired(): ApiError {
11
+ return new ApiError(403, "Authentication required");
12
+ }
13
+
14
+ static notFound(): ApiError {
15
+ return new ApiError(404, "Not found");
16
+ }
17
+
18
+ static other(code: number, message: string): ApiError {
19
+ return new ApiError(code, message);
20
+ }
21
+ }
22
+
23
+ export class ApiClientError extends Error {
24
+ constructor(
25
+ message: string,
26
+ public readonly cause?: ApiError | Error
27
+ ) {
28
+ super(message);
29
+ this.name = "ApiClientError";
30
+ }
31
+
32
+ static canNotDecodeResponseJson(error?: Error): ApiClientError {
33
+ return new ApiClientError("Cannot decode response JSON", error);
34
+ }
35
+
36
+ static apiError(error: ApiError): ApiClientError {
37
+ return new ApiClientError(`API error: ${error.message}`, error);
38
+ }
39
+
40
+ static requestFailed(error?: Error): ApiClientError {
41
+ return new ApiClientError("Request failed", error);
42
+ }
43
+ }
@@ -0,0 +1,4 @@
1
+ export { ApiClient, type ListEpisodesOptions, type ListSeriesOptions } from "./api-client.js";
2
+ export { isEmptyDate, parseApiDate } from "./date-decoder.js";
3
+ export { ApiClientError, ApiError } from "./errors.js";
4
+ export * from "./types/index.js";
@@ -0,0 +1,16 @@
1
+ import type { Translation } from "./translation.js";
2
+
3
+ export interface Episode {
4
+ id: number;
5
+ episodeFull: string;
6
+ episodeInt: string;
7
+ episodeType: string;
8
+ firstUploadedDateTime: Date;
9
+ isActive: number;
10
+ isFirstUploaded: number;
11
+ }
12
+
13
+ export interface EpisodeFull extends Episode {
14
+ seriesId: number;
15
+ translations: Translation[];
16
+ }
@@ -0,0 +1,15 @@
1
+ export type { Episode, EpisodeFull } from "./episode.js";
2
+ export type {
3
+ Series,
4
+ SeriesFull,
5
+ SeriesFullDescription,
6
+ SeriesFullGenre,
7
+ Titles,
8
+ } from "./series.js";
9
+ export type { SeriesType } from "./series-type.js";
10
+ export type {
11
+ Translation,
12
+ TranslationEmbed,
13
+ TranslationEmbedStream,
14
+ TranslationFull,
15
+ } from "./translation.js";
@@ -0,0 +1,10 @@
1
+ export type SeriesType =
2
+ | "tv"
3
+ | "tv_special"
4
+ | "movie"
5
+ | "ova"
6
+ | "music"
7
+ | "ona"
8
+ | "special"
9
+ | "pv"
10
+ | "cm";
@@ -0,0 +1,44 @@
1
+ import type { Episode } from "./episode.js";
2
+ import type { SeriesType } from "./series-type.js";
3
+
4
+ export interface Titles {
5
+ ru: string | null;
6
+ romaji: string | null;
7
+ }
8
+
9
+ export interface Series {
10
+ id: number;
11
+ title: string;
12
+ titles: Titles;
13
+ posterUrl: string | null;
14
+ myAnimeListScore: string;
15
+ season: string;
16
+ type: SeriesType | null;
17
+ year: number | null;
18
+ }
19
+
20
+ export interface SeriesFullGenre {
21
+ id: number;
22
+ title: string;
23
+ }
24
+
25
+ export interface SeriesFullDescription {
26
+ source: string;
27
+ value: string;
28
+ }
29
+
30
+ export interface SeriesFull {
31
+ id: number;
32
+ title: string;
33
+ posterUrl: string | null;
34
+ myAnimeListScore: string;
35
+ myAnimeListId: number;
36
+ isAiring: number;
37
+ numberOfEpisodes: number;
38
+ season: string;
39
+ type: SeriesType | null;
40
+ titles: Titles;
41
+ genres: SeriesFullGenre[] | null;
42
+ descriptions: SeriesFullDescription[] | null;
43
+ episodes: Episode[] | null;
44
+ }
@@ -0,0 +1,30 @@
1
+ import type { Episode } from "./episode.js";
2
+ import type { Series } from "./series.js";
3
+
4
+ export interface Translation {
5
+ id: number;
6
+ activeDateTime: Date;
7
+ addedDateTime: Date;
8
+ isActive: number;
9
+ qualityType: string;
10
+ typeKind: string;
11
+ typeLang: string;
12
+ authorsSummary: string;
13
+ height: number;
14
+ }
15
+
16
+ export interface TranslationFull {
17
+ episode: Episode;
18
+ series: Series;
19
+ }
20
+
21
+ export interface TranslationEmbedStream {
22
+ height: number;
23
+ urls: string[];
24
+ }
25
+
26
+ export interface TranslationEmbed {
27
+ stream: TranslationEmbedStream[];
28
+ subtitlesUrl: string | null;
29
+ subtitlesVttUrl: string | null;
30
+ }