ani-mcp 0.3.0 → 0.5.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 +1 -0
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +10 -5
- package/dist/api/queries.d.ts +20 -8
- package/dist/api/queries.js +164 -0
- package/dist/engine/franchise.d.ts +36 -0
- package/dist/engine/franchise.js +87 -0
- package/dist/engine/mood.d.ts +6 -0
- package/dist/engine/mood.js +64 -2
- package/dist/index.js +3 -1
- package/dist/schemas.d.ts +95 -0
- package/dist/schemas.js +155 -2
- package/dist/tools/discover.js +4 -4
- package/dist/tools/info.js +9 -7
- package/dist/tools/lists.js +55 -3
- package/dist/tools/recommend.js +330 -20
- package/dist/tools/search.js +9 -25
- package/dist/tools/social.d.ts +4 -0
- package/dist/tools/social.js +197 -0
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.js +91 -7
- package/dist/types.d.ts +226 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +17 -0
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# ani-mcp
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/ani-mcp)
|
|
8
|
+
[](https://www.npmjs.com/package/ani-mcp)
|
|
8
9
|
[](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml)
|
|
9
10
|
[](https://opensource.org/licenses/MIT)
|
|
10
11
|
[](https://nodejs.org)
|
package/dist/api/client.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Handles rate limiting (token bucket), retry with exponential backoff,
|
|
5
5
|
* and in-memory caching.
|
|
6
6
|
*/
|
|
7
|
-
import type { AniListMediaListEntry } from "../types.js";
|
|
7
|
+
import type { AniListMediaListEntry, UserListResponse } from "../types.js";
|
|
8
8
|
/** Per-category TTLs for the query cache */
|
|
9
9
|
export declare const CACHE_TTLS: {
|
|
10
10
|
readonly media: number;
|
|
@@ -31,6 +31,8 @@ declare class AniListClient {
|
|
|
31
31
|
constructor();
|
|
32
32
|
/** Execute a GraphQL query with caching and automatic retry */
|
|
33
33
|
query<T = unknown>(query: string, variables?: Record<string, unknown>, options?: QueryOptions): Promise<T>;
|
|
34
|
+
/** Fetch a user's media list groups with metadata (name, status, isCustomList) */
|
|
35
|
+
fetchListGroups(username: string, type: string, status?: string, sort?: string[]): Promise<UserListResponse["MediaListCollection"]["lists"]>;
|
|
34
36
|
/** Fetch a user's media list, flattened into a single array */
|
|
35
37
|
fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
|
|
36
38
|
/** Invalidate the entire query cache */
|
package/dist/api/client.js
CHANGED
|
@@ -9,8 +9,8 @@ import pRetry, { AbortError } from "p-retry";
|
|
|
9
9
|
import pThrottle from "p-throttle";
|
|
10
10
|
import { USER_LIST_QUERY } from "./queries.js";
|
|
11
11
|
const ANILIST_API_URL = process.env.ANILIST_API_URL || "https://graphql.anilist.co";
|
|
12
|
-
//
|
|
13
|
-
const RATE_LIMIT_PER_MINUTE = 85;
|
|
12
|
+
// No rate limit needed when API is mocked in tests
|
|
13
|
+
const RATE_LIMIT_PER_MINUTE = process.env.VITEST ? 10_000 : 85;
|
|
14
14
|
const MAX_RETRIES = 3;
|
|
15
15
|
// Hard timeout per fetch attempt (retries get their own timeout)
|
|
16
16
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -87,17 +87,22 @@ class AniListClient {
|
|
|
87
87
|
// No cache category - skip caching entirely
|
|
88
88
|
return this.executeWithRetry(query, variables);
|
|
89
89
|
}
|
|
90
|
-
/** Fetch a user's media list
|
|
91
|
-
async
|
|
90
|
+
/** Fetch a user's media list groups with metadata (name, status, isCustomList) */
|
|
91
|
+
async fetchListGroups(username, type, status, sort) {
|
|
92
92
|
const variables = { userName: username, type };
|
|
93
93
|
if (status)
|
|
94
94
|
variables.status = status;
|
|
95
95
|
if (sort)
|
|
96
96
|
variables.sort = sort;
|
|
97
97
|
const data = await this.query(USER_LIST_QUERY, variables, { cache: "list" });
|
|
98
|
+
return data.MediaListCollection.lists;
|
|
99
|
+
}
|
|
100
|
+
/** Fetch a user's media list, flattened into a single array */
|
|
101
|
+
async fetchList(username, type, status, sort) {
|
|
102
|
+
const lists = await this.fetchListGroups(username, type, status, sort);
|
|
98
103
|
// Flatten across status groups
|
|
99
104
|
const entries = [];
|
|
100
|
-
for (const list of
|
|
105
|
+
for (const list of lists) {
|
|
101
106
|
entries.push(...list.entries);
|
|
102
107
|
}
|
|
103
108
|
return entries;
|
package/dist/api/queries.d.ts
CHANGED
|
@@ -5,21 +5,21 @@
|
|
|
5
5
|
* if the AniList schema changes.
|
|
6
6
|
*/
|
|
7
7
|
/** Paginated search with optional genre, year, and format filters */
|
|
8
|
-
export declare const SEARCH_MEDIA_QUERY = "\n query SearchMedia(\n $search: String!\n $type: MediaType\n $genre: [String]\n $year: Int\n $format: MediaFormat\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n total\n currentPage\n lastPage\n hasNextPage\n }\n media(\n search: $search\n type: $type\n genre_in: $genre\n startDate_year: $year\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
8
|
+
export declare const SEARCH_MEDIA_QUERY = "\n query SearchMedia(\n $search: String!\n $type: MediaType\n $genre: [String]\n $year: Int\n $format: MediaFormat\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n total\n currentPage\n lastPage\n hasNextPage\n }\n media(\n search: $search\n type: $type\n genre_in: $genre\n startDate_year: $year\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
9
9
|
/** Full media lookup with relations and recommendations */
|
|
10
|
-
export declare const MEDIA_DETAILS_QUERY = "\n query MediaDetails($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n ...MediaFields\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n }\n }\n }\n recommendations(sort: RATING_DESC, perPage: 5) {\n nodes {\n rating\n mediaRecommendation {\n id\n title { romaji english }\n format\n meanScore\n genres\n siteUrl\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
10
|
+
export declare const MEDIA_DETAILS_QUERY = "\n query MediaDetails($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n ...MediaFields\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n }\n }\n }\n recommendations(sort: RATING_DESC, perPage: 5) {\n nodes {\n rating\n mediaRecommendation {\n id\n title { romaji english }\n format\n meanScore\n genres\n siteUrl\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
11
11
|
/** Discover top-rated titles by genre without a search term */
|
|
12
|
-
export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type: MediaType\n $genre_in: [String]\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, genre_in: $genre_in, sort: $sort) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
12
|
+
export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type: MediaType\n $genre_in: [String]\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, genre_in: $genre_in, sort: $sort) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
13
13
|
/** Browse anime by season and year */
|
|
14
|
-
export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
14
|
+
export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
15
15
|
/** User profile statistics - watching/reading stats, genre/tag/score breakdowns */
|
|
16
16
|
export declare const USER_STATS_QUERY = "\n query UserStats($name: String!) {\n User(name: $name) {\n id\n name\n mediaListOptions {\n scoreFormat\n }\n statistics {\n anime {\n count\n meanScore\n minutesWatched\n episodesWatched\n genres(sort: COUNT_DESC, limit: 10) {\n genre\n count\n meanScore\n minutesWatched\n }\n scores(sort: MEAN_SCORE_DESC) {\n score\n count\n }\n formats(sort: COUNT_DESC) {\n format\n count\n }\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n genres(sort: COUNT_DESC, limit: 10) {\n genre\n count\n meanScore\n chaptersRead\n }\n scores(sort: MEAN_SCORE_DESC) {\n score\n count\n }\n formats(sort: COUNT_DESC) {\n format\n count\n }\n }\n }\n }\n }\n";
|
|
17
17
|
/** Media recommendations for a given title */
|
|
18
|
-
export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
18
|
+
export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
19
19
|
/** Trending anime or manga right now */
|
|
20
|
-
export declare const TRENDING_MEDIA_QUERY = "\n query TrendingMedia(\n $type: MediaType\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, isAdult: $isAdult, sort: TRENDING_DESC) {\n ...MediaFields\n trending\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
20
|
+
export declare const TRENDING_MEDIA_QUERY = "\n query TrendingMedia(\n $type: MediaType\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, isAdult: $isAdult, sort: TRENDING_DESC) {\n ...MediaFields\n trending\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
21
21
|
/** Browse by genre without a search term, with optional filters */
|
|
22
|
-
export declare const GENRE_BROWSE_QUERY = "\n query GenreBrowse(\n $type: MediaType\n $genre_in: [String]\n $year: Int\n $status: MediaStatus\n $format: MediaFormat\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(\n type: $type\n genre_in: $genre_in\n startDate_year: $year\n status: $status\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
22
|
+
export declare const GENRE_BROWSE_QUERY = "\n query GenreBrowse(\n $type: MediaType\n $genre_in: [String]\n $year: Int\n $status: MediaStatus\n $format: MediaFormat\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(\n type: $type\n genre_in: $genre_in\n startDate_year: $year\n status: $status\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
23
23
|
/** Staff and voice actors for a media title */
|
|
24
24
|
export declare const STAFF_QUERY = "\n query MediaStaff($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n format\n siteUrl\n staff(sort: RELEVANCE, perPage: 15) {\n edges {\n role\n node {\n id\n name { full native }\n siteUrl\n }\n }\n }\n characters(sort: ROLE, perPage: 10) {\n edges {\n role\n node {\n id\n name { full native }\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full native }\n siteUrl\n }\n }\n }\n }\n }\n";
|
|
25
25
|
/** Airing schedule for currently airing anime */
|
|
@@ -31,12 +31,24 @@ export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaLis
|
|
|
31
31
|
/** Remove a list entry */
|
|
32
32
|
export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
|
|
33
33
|
/** User's anime/manga list, grouped by status. Omit $status to get all lists. */
|
|
34
|
-
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
34
|
+
export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
|
|
35
35
|
/** Search for staff by name with their top works */
|
|
36
36
|
export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
|
|
37
37
|
/** Authenticated user info */
|
|
38
38
|
export declare const VIEWER_QUERY = "\n query Viewer {\n Viewer {\n id\n name\n avatar { medium }\n siteUrl\n mediaListOptions {\n scoreFormat\n }\n }\n }\n";
|
|
39
39
|
/** All valid genres and media tags */
|
|
40
40
|
export declare const GENRE_TAG_COLLECTION_QUERY = "\n query GenreTagCollection {\n GenreCollection\n MediaTagCollection {\n name\n description\n category\n isAdult\n }\n }\n";
|
|
41
|
+
/** Toggle favourite on any entity type */
|
|
42
|
+
export declare const TOGGLE_FAVOURITE_MUTATION = "\n mutation ToggleFavourite(\n $animeId: Int\n $mangaId: Int\n $characterId: Int\n $staffId: Int\n $studioId: Int\n ) {\n ToggleFavourite(\n animeId: $animeId\n mangaId: $mangaId\n characterId: $characterId\n staffId: $staffId\n studioId: $studioId\n ) {\n anime { nodes { id } }\n manga { nodes { id } }\n characters { nodes { id } }\n staff { nodes { id } }\n studios { nodes { id } }\n }\n }\n";
|
|
43
|
+
/** Post a text activity to the authenticated user's feed */
|
|
44
|
+
export declare const SAVE_TEXT_ACTIVITY_MUTATION = "\n mutation SaveTextActivity($text: String!) {\n SaveTextActivity(text: $text) {\n id\n createdAt\n text\n user { name }\n }\n }\n";
|
|
45
|
+
/** Recent activity for a user, supports text and list activity types */
|
|
46
|
+
export declare const ACTIVITY_FEED_QUERY = "\n query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage hasNextPage }\n activities(userId: $userId, type: $type, sort: ID_DESC) {\n ... on TextActivity {\n __typename\n id\n text\n createdAt\n user { name }\n }\n ... on ListActivity {\n __typename\n id\n status\n progress\n createdAt\n user { name }\n media {\n id\n title { romaji english native }\n type\n }\n }\n }\n }\n }\n";
|
|
47
|
+
/** User profile with bio, stats summary, and top favourites */
|
|
48
|
+
export declare const USER_PROFILE_QUERY = "\n query UserProfile($name: String) {\n User(name: $name) {\n id\n name\n about\n avatar { large }\n bannerImage\n siteUrl\n createdAt\n updatedAt\n donatorTier\n statistics {\n anime {\n count\n meanScore\n episodesWatched\n minutesWatched\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n }\n }\n favourites {\n anime(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n manga(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n characters(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n staff(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n studios(perPage: 5) {\n nodes { id name siteUrl }\n }\n }\n }\n }\n";
|
|
49
|
+
/** Community reviews for a media title */
|
|
50
|
+
export declare const MEDIA_REVIEWS_QUERY = "\n query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n reviews(page: $page, perPage: $perPage, sort: $sort) {\n pageInfo { total hasNextPage }\n nodes {\n id\n score\n summary\n body\n rating\n ratingAmount\n createdAt\n user { name siteUrl }\n }\n }\n }\n }\n";
|
|
41
51
|
/** Search for a studio by name with their productions */
|
|
42
52
|
export declare const STUDIO_SEARCH_QUERY = "\n query StudioSearch($search: String!, $perPage: Int) {\n Studio(search: $search, sort: SEARCH_MATCH) {\n id\n name\n isAnimationStudio\n siteUrl\n media(sort: POPULARITY_DESC, perPage: $perPage) {\n edges {\n isMainStudio\n node {\n id\n title { romaji english }\n format\n type\n status\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n";
|
|
53
|
+
/** Batch-fetch relations for a list of media IDs */
|
|
54
|
+
export declare const BATCH_RELATIONS_QUERY = "\n query BatchRelations($ids: [Int]) {\n Page(perPage: 50) {\n media(id_in: $ids) {\n id\n title { romaji english }\n format\n status\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n season\n seasonYear\n }\n }\n }\n }\n }\n }\n";
|
package/dist/api/queries.js
CHANGED
|
@@ -17,6 +17,7 @@ const MEDIA_FRAGMENT = `
|
|
|
17
17
|
format
|
|
18
18
|
status
|
|
19
19
|
episodes
|
|
20
|
+
duration
|
|
20
21
|
chapters
|
|
21
22
|
volumes
|
|
22
23
|
meanScore
|
|
@@ -418,6 +419,7 @@ export const USER_LIST_QUERY = `
|
|
|
418
419
|
lists {
|
|
419
420
|
name
|
|
420
421
|
status
|
|
422
|
+
isCustomList
|
|
421
423
|
entries {
|
|
422
424
|
id
|
|
423
425
|
score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
|
|
@@ -489,6 +491,141 @@ export const GENRE_TAG_COLLECTION_QUERY = `
|
|
|
489
491
|
}
|
|
490
492
|
}
|
|
491
493
|
`;
|
|
494
|
+
// === 0.4.0 Social & Favourites ===
|
|
495
|
+
/** Toggle favourite on any entity type */
|
|
496
|
+
export const TOGGLE_FAVOURITE_MUTATION = `
|
|
497
|
+
mutation ToggleFavourite(
|
|
498
|
+
$animeId: Int
|
|
499
|
+
$mangaId: Int
|
|
500
|
+
$characterId: Int
|
|
501
|
+
$staffId: Int
|
|
502
|
+
$studioId: Int
|
|
503
|
+
) {
|
|
504
|
+
ToggleFavourite(
|
|
505
|
+
animeId: $animeId
|
|
506
|
+
mangaId: $mangaId
|
|
507
|
+
characterId: $characterId
|
|
508
|
+
staffId: $staffId
|
|
509
|
+
studioId: $studioId
|
|
510
|
+
) {
|
|
511
|
+
anime { nodes { id } }
|
|
512
|
+
manga { nodes { id } }
|
|
513
|
+
characters { nodes { id } }
|
|
514
|
+
staff { nodes { id } }
|
|
515
|
+
studios { nodes { id } }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
`;
|
|
519
|
+
/** Post a text activity to the authenticated user's feed */
|
|
520
|
+
export const SAVE_TEXT_ACTIVITY_MUTATION = `
|
|
521
|
+
mutation SaveTextActivity($text: String!) {
|
|
522
|
+
SaveTextActivity(text: $text) {
|
|
523
|
+
id
|
|
524
|
+
createdAt
|
|
525
|
+
text
|
|
526
|
+
user { name }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
`;
|
|
530
|
+
/** Recent activity for a user, supports text and list activity types */
|
|
531
|
+
export const ACTIVITY_FEED_QUERY = `
|
|
532
|
+
query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {
|
|
533
|
+
Page(page: $page, perPage: $perPage) {
|
|
534
|
+
pageInfo { total currentPage hasNextPage }
|
|
535
|
+
activities(userId: $userId, type: $type, sort: ID_DESC) {
|
|
536
|
+
... on TextActivity {
|
|
537
|
+
__typename
|
|
538
|
+
id
|
|
539
|
+
text
|
|
540
|
+
createdAt
|
|
541
|
+
user { name }
|
|
542
|
+
}
|
|
543
|
+
... on ListActivity {
|
|
544
|
+
__typename
|
|
545
|
+
id
|
|
546
|
+
status
|
|
547
|
+
progress
|
|
548
|
+
createdAt
|
|
549
|
+
user { name }
|
|
550
|
+
media {
|
|
551
|
+
id
|
|
552
|
+
title { romaji english native }
|
|
553
|
+
type
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
`;
|
|
560
|
+
/** User profile with bio, stats summary, and top favourites */
|
|
561
|
+
export const USER_PROFILE_QUERY = `
|
|
562
|
+
query UserProfile($name: String) {
|
|
563
|
+
User(name: $name) {
|
|
564
|
+
id
|
|
565
|
+
name
|
|
566
|
+
about
|
|
567
|
+
avatar { large }
|
|
568
|
+
bannerImage
|
|
569
|
+
siteUrl
|
|
570
|
+
createdAt
|
|
571
|
+
updatedAt
|
|
572
|
+
donatorTier
|
|
573
|
+
statistics {
|
|
574
|
+
anime {
|
|
575
|
+
count
|
|
576
|
+
meanScore
|
|
577
|
+
episodesWatched
|
|
578
|
+
minutesWatched
|
|
579
|
+
}
|
|
580
|
+
manga {
|
|
581
|
+
count
|
|
582
|
+
meanScore
|
|
583
|
+
chaptersRead
|
|
584
|
+
volumesRead
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
favourites {
|
|
588
|
+
anime(perPage: 5) {
|
|
589
|
+
nodes { id title { romaji english native } siteUrl }
|
|
590
|
+
}
|
|
591
|
+
manga(perPage: 5) {
|
|
592
|
+
nodes { id title { romaji english native } siteUrl }
|
|
593
|
+
}
|
|
594
|
+
characters(perPage: 5) {
|
|
595
|
+
nodes { id name { full } siteUrl }
|
|
596
|
+
}
|
|
597
|
+
staff(perPage: 5) {
|
|
598
|
+
nodes { id name { full } siteUrl }
|
|
599
|
+
}
|
|
600
|
+
studios(perPage: 5) {
|
|
601
|
+
nodes { id name siteUrl }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
`;
|
|
607
|
+
/** Community reviews for a media title */
|
|
608
|
+
export const MEDIA_REVIEWS_QUERY = `
|
|
609
|
+
query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {
|
|
610
|
+
Media(id: $id, search: $search) {
|
|
611
|
+
id
|
|
612
|
+
title { romaji english native }
|
|
613
|
+
reviews(page: $page, perPage: $perPage, sort: $sort) {
|
|
614
|
+
pageInfo { total hasNextPage }
|
|
615
|
+
nodes {
|
|
616
|
+
id
|
|
617
|
+
score
|
|
618
|
+
summary
|
|
619
|
+
body
|
|
620
|
+
rating
|
|
621
|
+
ratingAmount
|
|
622
|
+
createdAt
|
|
623
|
+
user { name siteUrl }
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
`;
|
|
492
629
|
/** Search for a studio by name with their productions */
|
|
493
630
|
export const STUDIO_SEARCH_QUERY = `
|
|
494
631
|
query StudioSearch($search: String!, $perPage: Int) {
|
|
@@ -514,3 +651,30 @@ export const STUDIO_SEARCH_QUERY = `
|
|
|
514
651
|
}
|
|
515
652
|
}
|
|
516
653
|
`;
|
|
654
|
+
/** Batch-fetch relations for a list of media IDs */
|
|
655
|
+
export const BATCH_RELATIONS_QUERY = `
|
|
656
|
+
query BatchRelations($ids: [Int]) {
|
|
657
|
+
Page(perPage: 50) {
|
|
658
|
+
media(id_in: $ids) {
|
|
659
|
+
id
|
|
660
|
+
title { romaji english }
|
|
661
|
+
format
|
|
662
|
+
status
|
|
663
|
+
relations {
|
|
664
|
+
edges {
|
|
665
|
+
relationType
|
|
666
|
+
node {
|
|
667
|
+
id
|
|
668
|
+
title { romaji english }
|
|
669
|
+
format
|
|
670
|
+
status
|
|
671
|
+
type
|
|
672
|
+
season
|
|
673
|
+
seasonYear
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
`;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Franchise graph traversal for watch order guidance. */
|
|
2
|
+
/** Single entry in a franchise watch order */
|
|
3
|
+
export interface FranchiseEntry {
|
|
4
|
+
id: number;
|
|
5
|
+
title: string;
|
|
6
|
+
format: string | null;
|
|
7
|
+
status: string | null;
|
|
8
|
+
type: "main" | "special";
|
|
9
|
+
}
|
|
10
|
+
/** Media entry with its relations */
|
|
11
|
+
export interface RelationNode {
|
|
12
|
+
id: number;
|
|
13
|
+
title: {
|
|
14
|
+
romaji: string | null;
|
|
15
|
+
english: string | null;
|
|
16
|
+
};
|
|
17
|
+
format: string | null;
|
|
18
|
+
status: string | null;
|
|
19
|
+
relations: {
|
|
20
|
+
edges: Array<{
|
|
21
|
+
relationType: string;
|
|
22
|
+
node: {
|
|
23
|
+
id: number;
|
|
24
|
+
title: {
|
|
25
|
+
romaji: string | null;
|
|
26
|
+
english: string | null;
|
|
27
|
+
};
|
|
28
|
+
format: string | null;
|
|
29
|
+
status: string | null;
|
|
30
|
+
type: string;
|
|
31
|
+
};
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Build a watch order by following SEQUEL edges from the franchise root */
|
|
36
|
+
export declare function buildWatchOrder(startId: number, relationsMap: Map<number, RelationNode>, includeSpecials: boolean): FranchiseEntry[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Franchise graph traversal for watch order guidance. */
|
|
2
|
+
// Main formats in a franchise timeline
|
|
3
|
+
const MAIN_FORMATS = new Set(["TV", "MOVIE", "ONA", "TV_SHORT"]);
|
|
4
|
+
// Max BFS depth to prevent runaway traversal
|
|
5
|
+
const MAX_DEPTH = 30;
|
|
6
|
+
/** Find the earliest entry by following PREQUEL edges backward */
|
|
7
|
+
function findRoot(startId, relationsMap) {
|
|
8
|
+
let current = startId;
|
|
9
|
+
const visited = new Set();
|
|
10
|
+
while (true) {
|
|
11
|
+
visited.add(current);
|
|
12
|
+
const node = relationsMap.get(current);
|
|
13
|
+
if (!node)
|
|
14
|
+
break;
|
|
15
|
+
const prequel = node.relations.edges.find((e) => e.relationType === "PREQUEL" && !visited.has(e.node.id));
|
|
16
|
+
if (!prequel)
|
|
17
|
+
break;
|
|
18
|
+
current = prequel.node.id;
|
|
19
|
+
}
|
|
20
|
+
return current;
|
|
21
|
+
}
|
|
22
|
+
/** Resolve format/status for a node from the map or from relation edges */
|
|
23
|
+
function resolveNodeInfo(id, relationsMap) {
|
|
24
|
+
// Direct lookup
|
|
25
|
+
const node = relationsMap.get(id);
|
|
26
|
+
if (node)
|
|
27
|
+
return { format: node.format, status: node.status };
|
|
28
|
+
// Fallback: scan relation edges from other nodes
|
|
29
|
+
for (const n of relationsMap.values()) {
|
|
30
|
+
for (const edge of n.relations.edges) {
|
|
31
|
+
if (edge.node.id === id) {
|
|
32
|
+
return { format: edge.node.format, status: edge.node.status };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { format: null, status: null };
|
|
37
|
+
}
|
|
38
|
+
/** Build a watch order by following SEQUEL edges from the franchise root */
|
|
39
|
+
export function buildWatchOrder(startId, relationsMap, includeSpecials) {
|
|
40
|
+
const rootId = findRoot(startId, relationsMap);
|
|
41
|
+
const entries = [];
|
|
42
|
+
const visited = new Set();
|
|
43
|
+
// BFS through sequel and side-story edges
|
|
44
|
+
const queue = [rootId];
|
|
45
|
+
let depth = 0;
|
|
46
|
+
while (queue.length > 0 && depth < MAX_DEPTH) {
|
|
47
|
+
const id = queue.shift();
|
|
48
|
+
if (id === undefined || visited.has(id))
|
|
49
|
+
continue;
|
|
50
|
+
visited.add(id);
|
|
51
|
+
depth++;
|
|
52
|
+
const node = relationsMap.get(id);
|
|
53
|
+
const title = node
|
|
54
|
+
? (node.title.english ?? node.title.romaji ?? "Unknown")
|
|
55
|
+
: "Unknown";
|
|
56
|
+
const { format, status } = resolveNodeInfo(id, relationsMap);
|
|
57
|
+
const isMain = MAIN_FORMATS.has(format ?? "");
|
|
58
|
+
if (isMain || includeSpecials) {
|
|
59
|
+
entries.push({
|
|
60
|
+
id,
|
|
61
|
+
title,
|
|
62
|
+
format,
|
|
63
|
+
status,
|
|
64
|
+
type: isMain ? "main" : "special",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!node)
|
|
68
|
+
continue;
|
|
69
|
+
// Collect sequels and side stories
|
|
70
|
+
const sequels = [];
|
|
71
|
+
const sides = [];
|
|
72
|
+
for (const edge of node.relations.edges) {
|
|
73
|
+
if (visited.has(edge.node.id))
|
|
74
|
+
continue;
|
|
75
|
+
if (edge.relationType === "SEQUEL") {
|
|
76
|
+
sequels.push(edge.node.id);
|
|
77
|
+
}
|
|
78
|
+
else if (includeSpecials &&
|
|
79
|
+
(edge.relationType === "SIDE_STORY" || edge.relationType === "SPIN_OFF")) {
|
|
80
|
+
sides.push(edge.node.id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Side stories appear after their parent, before the next sequel
|
|
84
|
+
queue.push(...sides, ...sequels);
|
|
85
|
+
}
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
package/dist/engine/mood.d.ts
CHANGED
|
@@ -9,12 +9,18 @@ export interface MoodModifiers {
|
|
|
9
9
|
penalizeGenres: Set<string>;
|
|
10
10
|
penalizeTags: Set<string>;
|
|
11
11
|
}
|
|
12
|
+
export declare function loadCustomMoods(): void;
|
|
12
13
|
/** Parse a freeform mood string into genre/tag boost and penalize sets */
|
|
13
14
|
export declare function parseMood(mood: string): MoodModifiers;
|
|
14
15
|
/** Check whether a mood string matches any known keywords */
|
|
15
16
|
export declare function hasMoodMatch(mood: string): boolean;
|
|
16
17
|
/** List all recognized mood keywords */
|
|
17
18
|
export declare function getMoodKeywords(): string[];
|
|
19
|
+
/** Extract mood keywords as separate genre and tag arrays */
|
|
20
|
+
export declare function parseMoodFilters(mood: string): {
|
|
21
|
+
genres: string[];
|
|
22
|
+
tags: string[];
|
|
23
|
+
};
|
|
18
24
|
/** Suggest mood keywords that fit the current anime season */
|
|
19
25
|
export declare function seasonalMoodSuggestions(): {
|
|
20
26
|
season: string;
|
package/dist/engine/mood.js
CHANGED
|
@@ -146,16 +146,44 @@ const MOOD_SYNONYMS = {
|
|
|
146
146
|
// competitive
|
|
147
147
|
rivalry: "competitive",
|
|
148
148
|
tournament: "competitive",
|
|
149
|
+
// additional natural language terms
|
|
150
|
+
psychological: "brainy",
|
|
151
|
+
thoughtful: "brainy",
|
|
152
|
+
battle: "action",
|
|
153
|
+
fighting: "action",
|
|
154
|
+
heartfelt: "sad",
|
|
155
|
+
touching: "sad",
|
|
156
|
+
lighthearted: "wholesome",
|
|
157
|
+
feel: "wholesome",
|
|
158
|
+
feels: "wholesome",
|
|
159
|
+
suspense: "intense",
|
|
160
|
+
suspenseful: "intense",
|
|
149
161
|
};
|
|
150
162
|
// Merge base rules and synonyms into a single lookup
|
|
151
163
|
const MOOD_RULES = { ...BASE_MOOD_RULES };
|
|
152
164
|
for (const [synonym, base] of Object.entries(MOOD_SYNONYMS)) {
|
|
153
165
|
MOOD_RULES[synonym] = BASE_MOOD_RULES[base];
|
|
154
166
|
}
|
|
167
|
+
// Load user-defined mood overrides from env
|
|
168
|
+
export function loadCustomMoods() {
|
|
169
|
+
const raw = process.env.ANILIST_MOOD_CONFIG;
|
|
170
|
+
if (!raw)
|
|
171
|
+
return;
|
|
172
|
+
try {
|
|
173
|
+
const custom = JSON.parse(raw);
|
|
174
|
+
for (const [key, rule] of Object.entries(custom)) {
|
|
175
|
+
MOOD_RULES[key.toLowerCase()] = rule;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
console.warn("[ani-mcp] Invalid ANILIST_MOOD_CONFIG JSON, using defaults.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
loadCustomMoods();
|
|
155
183
|
// === Mood Parser ===
|
|
156
184
|
/** Parse a freeform mood string into genre/tag boost and penalize sets */
|
|
157
185
|
export function parseMood(mood) {
|
|
158
|
-
//
|
|
186
|
+
// Lowercase tokens, stripped of punctuation
|
|
159
187
|
const words = mood
|
|
160
188
|
.toLowerCase()
|
|
161
189
|
.replace(/[^a-z\s]/g, "")
|
|
@@ -168,7 +196,7 @@ export function parseMood(mood) {
|
|
|
168
196
|
const rule = MOOD_RULES[word];
|
|
169
197
|
if (!rule)
|
|
170
198
|
continue;
|
|
171
|
-
// Add to both
|
|
199
|
+
// Add to both sets (matcher checks genres and tags separately)
|
|
172
200
|
for (const name of rule.boost) {
|
|
173
201
|
boostGenres.add(name);
|
|
174
202
|
boostTags.add(name);
|
|
@@ -192,6 +220,40 @@ export function hasMoodMatch(mood) {
|
|
|
192
220
|
export function getMoodKeywords() {
|
|
193
221
|
return Object.keys(MOOD_RULES);
|
|
194
222
|
}
|
|
223
|
+
// AniList's fixed genre set (stable, rarely changes)
|
|
224
|
+
const ANILIST_GENRES = new Set([
|
|
225
|
+
"Action",
|
|
226
|
+
"Adventure",
|
|
227
|
+
"Comedy",
|
|
228
|
+
"Drama",
|
|
229
|
+
"Ecchi",
|
|
230
|
+
"Fantasy",
|
|
231
|
+
"Horror",
|
|
232
|
+
"Mahou Shoujo",
|
|
233
|
+
"Mecha",
|
|
234
|
+
"Music",
|
|
235
|
+
"Mystery",
|
|
236
|
+
"Psychological",
|
|
237
|
+
"Romance",
|
|
238
|
+
"Sci-Fi",
|
|
239
|
+
"Slice of Life",
|
|
240
|
+
"Sports",
|
|
241
|
+
"Supernatural",
|
|
242
|
+
"Thriller",
|
|
243
|
+
]);
|
|
244
|
+
/** Extract mood keywords as separate genre and tag arrays */
|
|
245
|
+
export function parseMoodFilters(mood) {
|
|
246
|
+
const mods = parseMood(mood);
|
|
247
|
+
const genres = [];
|
|
248
|
+
const tags = [];
|
|
249
|
+
for (const name of mods.boostGenres) {
|
|
250
|
+
if (ANILIST_GENRES.has(name))
|
|
251
|
+
genres.push(name);
|
|
252
|
+
else
|
|
253
|
+
tags.push(name);
|
|
254
|
+
}
|
|
255
|
+
return { genres, tags };
|
|
256
|
+
}
|
|
195
257
|
// === Seasonal Suggestions ===
|
|
196
258
|
const SEASONAL_MOODS = {
|
|
197
259
|
WINTER: ["cozy", "dark", "nostalgic", "brainy"],
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerRecommendTools } from "./tools/recommend.js";
|
|
|
8
8
|
import { registerDiscoverTools } from "./tools/discover.js";
|
|
9
9
|
import { registerInfoTools } from "./tools/info.js";
|
|
10
10
|
import { registerWriteTools } from "./tools/write.js";
|
|
11
|
+
import { registerSocialTools } from "./tools/social.js";
|
|
11
12
|
// Both vars are optional - warn on missing so operators know what's available
|
|
12
13
|
if (!process.env.ANILIST_USERNAME) {
|
|
13
14
|
console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
|
|
@@ -17,7 +18,7 @@ if (!process.env.ANILIST_TOKEN) {
|
|
|
17
18
|
}
|
|
18
19
|
const server = new FastMCP({
|
|
19
20
|
name: "ani-mcp",
|
|
20
|
-
version: "0.
|
|
21
|
+
version: "0.5.0",
|
|
21
22
|
});
|
|
22
23
|
registerSearchTools(server);
|
|
23
24
|
registerListTools(server);
|
|
@@ -25,6 +26,7 @@ registerRecommendTools(server);
|
|
|
25
26
|
registerDiscoverTools(server);
|
|
26
27
|
registerInfoTools(server);
|
|
27
28
|
registerWriteTools(server);
|
|
29
|
+
registerSocialTools(server);
|
|
28
30
|
// === Transport ===
|
|
29
31
|
const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
|
|
30
32
|
if (transport === "httpStream") {
|