ani-mcp 0.6.2 → 0.7.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/dist/engine/analytics.d.ts +46 -0
- package/dist/engine/analytics.js +264 -0
- package/dist/engine/taste.js +1 -8
- package/dist/index.js +3 -1
- package/dist/schemas.d.ts +60 -0
- package/dist/schemas.js +88 -0
- package/dist/tools/analytics.d.ts +4 -0
- package/dist/tools/analytics.js +455 -0
- package/dist/utils.d.ts +3 -1
- package/dist/utils.js +8 -0
- package/manifest.json +19 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Analytics engine: scoring calibration, drop patterns, and genre evolution. */
|
|
2
|
+
import type { AniListMediaListEntry } from "../types.js";
|
|
3
|
+
export interface GenreCalibration {
|
|
4
|
+
genre: string;
|
|
5
|
+
userMean: number;
|
|
6
|
+
communityMean: number;
|
|
7
|
+
delta: number;
|
|
8
|
+
count: number;
|
|
9
|
+
}
|
|
10
|
+
export interface CalibrationResult {
|
|
11
|
+
overallDelta: number;
|
|
12
|
+
tendency: "generous" | "harsh" | "average";
|
|
13
|
+
genreCalibrations: GenreCalibration[];
|
|
14
|
+
totalScored: number;
|
|
15
|
+
}
|
|
16
|
+
export interface DropCluster {
|
|
17
|
+
label: string;
|
|
18
|
+
type: "genre" | "tag";
|
|
19
|
+
dropCount: number;
|
|
20
|
+
totalCount: number;
|
|
21
|
+
dropRate: number;
|
|
22
|
+
medianDropPoint: number;
|
|
23
|
+
}
|
|
24
|
+
export interface DropAnalysis {
|
|
25
|
+
totalDropped: number;
|
|
26
|
+
clusters: DropCluster[];
|
|
27
|
+
earlyDrops: number;
|
|
28
|
+
avgDropProgress: number;
|
|
29
|
+
}
|
|
30
|
+
export interface GenreEra {
|
|
31
|
+
period: string;
|
|
32
|
+
startYear: number;
|
|
33
|
+
endYear: number;
|
|
34
|
+
topGenres: string[];
|
|
35
|
+
count: number;
|
|
36
|
+
}
|
|
37
|
+
export interface EvolutionResult {
|
|
38
|
+
eras: GenreEra[];
|
|
39
|
+
shifts: string[];
|
|
40
|
+
}
|
|
41
|
+
/** Per-genre scoring bias relative to community consensus */
|
|
42
|
+
export declare function computeCalibration(entries: AniListMediaListEntry[]): CalibrationResult;
|
|
43
|
+
/** Genre and tag clusters in a user's dropped titles */
|
|
44
|
+
export declare function analyzeDrops(droppedEntries: AniListMediaListEntry[], allEntries: AniListMediaListEntry[]): DropAnalysis;
|
|
45
|
+
/** How genre preferences shifted across time windows */
|
|
46
|
+
export declare function computeGenreEvolution(entries: AniListMediaListEntry[], windowYears?: number): EvolutionResult;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Analytics engine: scoring calibration, drop patterns, and genre evolution. */
|
|
2
|
+
// === Constants ===
|
|
3
|
+
// Min entries per genre for meaningful calibration
|
|
4
|
+
const MIN_GENRE_ENTRIES = 3;
|
|
5
|
+
// Min drops in a cluster to report
|
|
6
|
+
const MIN_CLUSTER_DROPS = 3;
|
|
7
|
+
// Max tag clusters to report
|
|
8
|
+
const MAX_TAG_CLUSTERS = 10;
|
|
9
|
+
// === Score Calibration ===
|
|
10
|
+
/** Per-genre scoring bias relative to community consensus */
|
|
11
|
+
export function computeCalibration(entries) {
|
|
12
|
+
// Filter to scored entries with community scores
|
|
13
|
+
const scored = entries.filter((e) => e.score > 0 && e.media.meanScore != null && e.media.meanScore > 0);
|
|
14
|
+
if (scored.length === 0) {
|
|
15
|
+
return {
|
|
16
|
+
overallDelta: 0,
|
|
17
|
+
tendency: "average",
|
|
18
|
+
genreCalibrations: [],
|
|
19
|
+
totalScored: 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// Overall delta: user mean - community mean (on 1-10 scale)
|
|
23
|
+
let userSum = 0;
|
|
24
|
+
let communitySum = 0;
|
|
25
|
+
for (const e of scored) {
|
|
26
|
+
userSum += e.score;
|
|
27
|
+
communitySum += (e.media.meanScore ?? 0) / 10;
|
|
28
|
+
}
|
|
29
|
+
const overallDelta = userSum / scored.length - communitySum / scored.length;
|
|
30
|
+
// Per-genre calibration
|
|
31
|
+
const genreMap = new Map();
|
|
32
|
+
for (const e of scored) {
|
|
33
|
+
const community = (e.media.meanScore ?? 0) / 10;
|
|
34
|
+
for (const genre of e.media.genres) {
|
|
35
|
+
let bucket = genreMap.get(genre);
|
|
36
|
+
if (!bucket) {
|
|
37
|
+
bucket = { userScores: [], communityScores: [] };
|
|
38
|
+
genreMap.set(genre, bucket);
|
|
39
|
+
}
|
|
40
|
+
bucket.userScores.push(e.score);
|
|
41
|
+
bucket.communityScores.push(community);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const genreCalibrations = [];
|
|
45
|
+
for (const [genre, bucket] of genreMap) {
|
|
46
|
+
if (bucket.userScores.length < MIN_GENRE_ENTRIES)
|
|
47
|
+
continue;
|
|
48
|
+
const userMean = mean(bucket.userScores);
|
|
49
|
+
const communityMean = mean(bucket.communityScores);
|
|
50
|
+
genreCalibrations.push({
|
|
51
|
+
genre,
|
|
52
|
+
userMean,
|
|
53
|
+
communityMean,
|
|
54
|
+
delta: userMean - communityMean,
|
|
55
|
+
count: bucket.userScores.length,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Sort by absolute delta descending
|
|
59
|
+
genreCalibrations.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
60
|
+
const tendency = overallDelta >= 0.5
|
|
61
|
+
? "generous"
|
|
62
|
+
: overallDelta <= -0.5
|
|
63
|
+
? "harsh"
|
|
64
|
+
: "average";
|
|
65
|
+
return {
|
|
66
|
+
overallDelta,
|
|
67
|
+
tendency,
|
|
68
|
+
genreCalibrations,
|
|
69
|
+
totalScored: scored.length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// === Drop Pattern Analysis ===
|
|
73
|
+
/** Genre and tag clusters in a user's dropped titles */
|
|
74
|
+
export function analyzeDrops(droppedEntries, allEntries) {
|
|
75
|
+
if (droppedEntries.length === 0) {
|
|
76
|
+
return { totalDropped: 0, clusters: [], earlyDrops: 0, avgDropProgress: 0 };
|
|
77
|
+
}
|
|
78
|
+
// Count total entries per genre/tag across all statuses
|
|
79
|
+
const genreTotals = new Map();
|
|
80
|
+
const tagTotals = new Map();
|
|
81
|
+
for (const e of allEntries) {
|
|
82
|
+
for (const g of e.media.genres) {
|
|
83
|
+
genreTotals.set(g, (genreTotals.get(g) ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
for (const t of e.media.tags) {
|
|
86
|
+
if (!t.isMediaSpoiler) {
|
|
87
|
+
tagTotals.set(t.name, (tagTotals.get(t.name) ?? 0) + 1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Genre drop counts and progress at drop
|
|
92
|
+
const genreDrops = new Map();
|
|
93
|
+
const tagDrops = new Map();
|
|
94
|
+
let earlyDrops = 0;
|
|
95
|
+
let totalProgress = 0;
|
|
96
|
+
let progressCount = 0;
|
|
97
|
+
for (const e of droppedEntries) {
|
|
98
|
+
const total = e.media.episodes ?? e.media.chapters ?? 0;
|
|
99
|
+
const progressPct = total > 0 ? e.progress / total : 0;
|
|
100
|
+
if (total > 0 && progressPct < 0.25)
|
|
101
|
+
earlyDrops++;
|
|
102
|
+
if (total > 0) {
|
|
103
|
+
totalProgress += progressPct;
|
|
104
|
+
progressCount++;
|
|
105
|
+
}
|
|
106
|
+
for (const g of e.media.genres) {
|
|
107
|
+
let arr = genreDrops.get(g);
|
|
108
|
+
if (!arr) {
|
|
109
|
+
arr = [];
|
|
110
|
+
genreDrops.set(g, arr);
|
|
111
|
+
}
|
|
112
|
+
if (total > 0)
|
|
113
|
+
arr.push(progressPct);
|
|
114
|
+
else
|
|
115
|
+
arr.push(-1); // unknown progress
|
|
116
|
+
}
|
|
117
|
+
for (const t of e.media.tags) {
|
|
118
|
+
if (t.isMediaSpoiler)
|
|
119
|
+
continue;
|
|
120
|
+
let arr = tagDrops.get(t.name);
|
|
121
|
+
if (!arr) {
|
|
122
|
+
arr = [];
|
|
123
|
+
tagDrops.set(t.name, arr);
|
|
124
|
+
}
|
|
125
|
+
if (total > 0)
|
|
126
|
+
arr.push(progressPct);
|
|
127
|
+
else
|
|
128
|
+
arr.push(-1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const clusters = [];
|
|
132
|
+
// Genre clusters
|
|
133
|
+
for (const [genre, progresses] of genreDrops) {
|
|
134
|
+
if (progresses.length < MIN_CLUSTER_DROPS)
|
|
135
|
+
continue;
|
|
136
|
+
const total = genreTotals.get(genre) ?? progresses.length;
|
|
137
|
+
const known = progresses.filter((p) => p >= 0);
|
|
138
|
+
clusters.push({
|
|
139
|
+
label: genre,
|
|
140
|
+
type: "genre",
|
|
141
|
+
dropCount: progresses.length,
|
|
142
|
+
totalCount: total,
|
|
143
|
+
dropRate: progresses.length / total,
|
|
144
|
+
medianDropPoint: known.length > 0 ? median(known) : 0,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// Tag clusters
|
|
148
|
+
for (const [tag, progresses] of tagDrops) {
|
|
149
|
+
if (progresses.length < MIN_CLUSTER_DROPS)
|
|
150
|
+
continue;
|
|
151
|
+
const total = tagTotals.get(tag) ?? progresses.length;
|
|
152
|
+
const known = progresses.filter((p) => p >= 0);
|
|
153
|
+
clusters.push({
|
|
154
|
+
label: tag,
|
|
155
|
+
type: "tag",
|
|
156
|
+
dropCount: progresses.length,
|
|
157
|
+
totalCount: total,
|
|
158
|
+
dropRate: progresses.length / total,
|
|
159
|
+
medianDropPoint: known.length > 0 ? median(known) : 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Sort by drop rate descending, cap tag clusters
|
|
163
|
+
clusters.sort((a, b) => b.dropRate - a.dropRate);
|
|
164
|
+
// Keep all genre clusters, cap tags
|
|
165
|
+
const genreClusters = clusters.filter((c) => c.type === "genre");
|
|
166
|
+
const tagClusters = clusters
|
|
167
|
+
.filter((c) => c.type === "tag")
|
|
168
|
+
.slice(0, MAX_TAG_CLUSTERS);
|
|
169
|
+
const merged = [...genreClusters, ...tagClusters].sort((a, b) => b.dropRate - a.dropRate);
|
|
170
|
+
return {
|
|
171
|
+
totalDropped: droppedEntries.length,
|
|
172
|
+
clusters: merged,
|
|
173
|
+
earlyDrops,
|
|
174
|
+
avgDropProgress: progressCount > 0 ? totalProgress / progressCount : 0,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// === Genre Evolution ===
|
|
178
|
+
/** How genre preferences shifted across time windows */
|
|
179
|
+
export function computeGenreEvolution(entries, windowYears = 2) {
|
|
180
|
+
// Filter to entries with valid completion dates
|
|
181
|
+
const dated = entries.filter((e) => e.completedAt.year != null);
|
|
182
|
+
if (dated.length === 0) {
|
|
183
|
+
return { eras: [], shifts: [] };
|
|
184
|
+
}
|
|
185
|
+
// Find date range
|
|
186
|
+
const years = dated.map((e) => e.completedAt.year);
|
|
187
|
+
const minYear = Math.min(...years);
|
|
188
|
+
const maxYear = Math.max(...years);
|
|
189
|
+
const span = maxYear - minYear + 1;
|
|
190
|
+
// Use 1-year windows if span is narrow
|
|
191
|
+
const winSize = span < 4 ? 1 : windowYears;
|
|
192
|
+
// Build windows
|
|
193
|
+
const windows = [];
|
|
194
|
+
let start = minYear;
|
|
195
|
+
while (start <= maxYear) {
|
|
196
|
+
const end = Math.min(start + winSize - 1, maxYear);
|
|
197
|
+
windows.push({ start, end });
|
|
198
|
+
start = end + 1;
|
|
199
|
+
}
|
|
200
|
+
const eras = [];
|
|
201
|
+
for (const win of windows) {
|
|
202
|
+
const windowEntries = dated.filter((e) => {
|
|
203
|
+
const y = e.completedAt.year;
|
|
204
|
+
return y >= win.start && y <= win.end;
|
|
205
|
+
});
|
|
206
|
+
if (windowEntries.length === 0)
|
|
207
|
+
continue;
|
|
208
|
+
// Count genres
|
|
209
|
+
const genreCounts = new Map();
|
|
210
|
+
for (const e of windowEntries) {
|
|
211
|
+
for (const g of e.media.genres) {
|
|
212
|
+
genreCounts.set(g, (genreCounts.get(g) ?? 0) + 1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Top 5 genres by count
|
|
216
|
+
const topGenres = [...genreCounts.entries()]
|
|
217
|
+
.sort((a, b) => b[1] - a[1])
|
|
218
|
+
.slice(0, 5)
|
|
219
|
+
.map(([g]) => g);
|
|
220
|
+
const period = win.start === win.end ? `${win.start}` : `${win.start}-${win.end}`;
|
|
221
|
+
eras.push({
|
|
222
|
+
period,
|
|
223
|
+
startYear: win.start,
|
|
224
|
+
endYear: win.end,
|
|
225
|
+
topGenres,
|
|
226
|
+
count: windowEntries.length,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Generate shift descriptions
|
|
230
|
+
const shifts = [];
|
|
231
|
+
for (let i = 1; i < eras.length; i++) {
|
|
232
|
+
const prev = eras[i - 1];
|
|
233
|
+
const curr = eras[i];
|
|
234
|
+
// Genres that appeared in current top 5 but not previous
|
|
235
|
+
const risen = curr.topGenres.filter((g) => !prev.topGenres.includes(g));
|
|
236
|
+
const dropped = prev.topGenres.filter((g) => !curr.topGenres.includes(g));
|
|
237
|
+
if (risen.length > 0) {
|
|
238
|
+
shifts.push(`${curr.period}: ${risen.join(", ")} rose into top genres`);
|
|
239
|
+
}
|
|
240
|
+
if (dropped.length > 0) {
|
|
241
|
+
shifts.push(`${curr.period}: ${dropped.join(", ")} dropped out of top genres`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { eras, shifts };
|
|
245
|
+
}
|
|
246
|
+
// === Helpers ===
|
|
247
|
+
function mean(values) {
|
|
248
|
+
if (values.length === 0)
|
|
249
|
+
return 0;
|
|
250
|
+
let sum = 0;
|
|
251
|
+
for (const v of values)
|
|
252
|
+
sum += v;
|
|
253
|
+
return sum / values.length;
|
|
254
|
+
}
|
|
255
|
+
function median(values) {
|
|
256
|
+
if (values.length === 0)
|
|
257
|
+
return 0;
|
|
258
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
259
|
+
const mid = Math.floor(sorted.length / 2);
|
|
260
|
+
if (sorted.length % 2 === 0) {
|
|
261
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
262
|
+
}
|
|
263
|
+
return sorted[mid];
|
|
264
|
+
}
|
package/dist/engine/taste.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Builds a weighted taste profile from a user's scored anime/manga list */
|
|
2
|
+
import { dateToEpoch } from "../utils.js";
|
|
2
3
|
// === Constants ===
|
|
3
4
|
// AniList community mean hovers around 7.0-7.2
|
|
4
5
|
const SITE_MEAN = 7.0;
|
|
@@ -176,14 +177,6 @@ function computeDecay(entry) {
|
|
|
176
177
|
const yearsSince = (now - epoch) / (365.25 * 24 * 3600);
|
|
177
178
|
return Math.exp(-DECAY_LAMBDA * Math.max(0, yearsSince));
|
|
178
179
|
}
|
|
179
|
-
/** Convert an AniListDate to Unix epoch, or null if incomplete */
|
|
180
|
-
function dateToEpoch(date) {
|
|
181
|
-
if (date.year == null)
|
|
182
|
-
return null;
|
|
183
|
-
const month = date.month ?? 1;
|
|
184
|
-
const day = date.day ?? 1;
|
|
185
|
-
return new Date(date.year, month - 1, day).getTime() / 1000;
|
|
186
|
-
}
|
|
187
180
|
/** Empty profile for users with too few scored entries */
|
|
188
181
|
function emptyProfile(totalCompleted) {
|
|
189
182
|
return {
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { registerDiscoverTools } from "./tools/discover.js";
|
|
|
9
9
|
import { registerInfoTools } from "./tools/info.js";
|
|
10
10
|
import { registerWriteTools } from "./tools/write.js";
|
|
11
11
|
import { registerSocialTools } from "./tools/social.js";
|
|
12
|
+
import { registerAnalyticsTools } from "./tools/analytics.js";
|
|
12
13
|
import { registerResources } from "./resources.js";
|
|
13
14
|
import { registerPrompts } from "./prompts.js";
|
|
14
15
|
// Both vars are optional - warn on missing so operators know what's available
|
|
@@ -20,7 +21,7 @@ if (!process.env.ANILIST_TOKEN) {
|
|
|
20
21
|
}
|
|
21
22
|
const server = new FastMCP({
|
|
22
23
|
name: "ani-mcp",
|
|
23
|
-
version: "0.
|
|
24
|
+
version: "0.7.0",
|
|
24
25
|
});
|
|
25
26
|
registerSearchTools(server);
|
|
26
27
|
registerListTools(server);
|
|
@@ -29,6 +30,7 @@ registerDiscoverTools(server);
|
|
|
29
30
|
registerInfoTools(server);
|
|
30
31
|
registerWriteTools(server);
|
|
31
32
|
registerSocialTools(server);
|
|
33
|
+
registerAnalyticsTools(server);
|
|
32
34
|
registerResources(server);
|
|
33
35
|
registerPrompts(server);
|
|
34
36
|
// === Transport ===
|
package/dist/schemas.d.ts
CHANGED
|
@@ -363,3 +363,63 @@ export declare const ReviewsInputSchema: z.ZodObject<{
|
|
|
363
363
|
page: z.ZodDefault<z.ZodNumber>;
|
|
364
364
|
}, z.core.$strip>;
|
|
365
365
|
export type ReviewsInput = z.infer<typeof ReviewsInputSchema>;
|
|
366
|
+
/** Input for score calibration analysis */
|
|
367
|
+
export declare const CalibrationInputSchema: z.ZodObject<{
|
|
368
|
+
username: z.ZodOptional<z.ZodString>;
|
|
369
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
370
|
+
ANIME: "ANIME";
|
|
371
|
+
MANGA: "MANGA";
|
|
372
|
+
}>>;
|
|
373
|
+
}, z.core.$strip>;
|
|
374
|
+
export type CalibrationInput = z.infer<typeof CalibrationInputSchema>;
|
|
375
|
+
/** Input for drop pattern analysis */
|
|
376
|
+
export declare const DropPatternInputSchema: z.ZodObject<{
|
|
377
|
+
username: z.ZodOptional<z.ZodString>;
|
|
378
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
379
|
+
ANIME: "ANIME";
|
|
380
|
+
MANGA: "MANGA";
|
|
381
|
+
}>>;
|
|
382
|
+
}, z.core.$strip>;
|
|
383
|
+
export type DropPatternInput = z.infer<typeof DropPatternInputSchema>;
|
|
384
|
+
/** Input for genre evolution over time */
|
|
385
|
+
export declare const EvolutionInputSchema: z.ZodObject<{
|
|
386
|
+
username: z.ZodOptional<z.ZodString>;
|
|
387
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
388
|
+
ANIME: "ANIME";
|
|
389
|
+
MANGA: "MANGA";
|
|
390
|
+
}>>;
|
|
391
|
+
}, z.core.$strip>;
|
|
392
|
+
export type EvolutionInput = z.infer<typeof EvolutionInputSchema>;
|
|
393
|
+
/** Input for franchise completion tracking */
|
|
394
|
+
export declare const CompletionistInputSchema: z.ZodObject<{
|
|
395
|
+
username: z.ZodOptional<z.ZodString>;
|
|
396
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
397
|
+
ANIME: "ANIME";
|
|
398
|
+
MANGA: "MANGA";
|
|
399
|
+
}>>;
|
|
400
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
401
|
+
}, z.core.$strip>;
|
|
402
|
+
export type CompletionistInput = z.infer<typeof CompletionistInputSchema>;
|
|
403
|
+
/** Input for seasonal pick-up and completion rates */
|
|
404
|
+
export declare const SeasonalHitRateInputSchema: z.ZodObject<{
|
|
405
|
+
username: z.ZodOptional<z.ZodString>;
|
|
406
|
+
season: z.ZodOptional<z.ZodEnum<{
|
|
407
|
+
WINTER: "WINTER";
|
|
408
|
+
SPRING: "SPRING";
|
|
409
|
+
SUMMER: "SUMMER";
|
|
410
|
+
FALL: "FALL";
|
|
411
|
+
}>>;
|
|
412
|
+
year: z.ZodOptional<z.ZodNumber>;
|
|
413
|
+
history: z.ZodDefault<z.ZodNumber>;
|
|
414
|
+
}, z.core.$strip>;
|
|
415
|
+
export type SeasonalHitRateInput = z.infer<typeof SeasonalHitRateInputSchema>;
|
|
416
|
+
/** Input for pace estimation */
|
|
417
|
+
export declare const PaceInputSchema: z.ZodObject<{
|
|
418
|
+
username: z.ZodOptional<z.ZodString>;
|
|
419
|
+
mediaId: z.ZodOptional<z.ZodNumber>;
|
|
420
|
+
type: z.ZodDefault<z.ZodEnum<{
|
|
421
|
+
ANIME: "ANIME";
|
|
422
|
+
MANGA: "MANGA";
|
|
423
|
+
}>>;
|
|
424
|
+
}, z.core.$strip>;
|
|
425
|
+
export type PaceInput = z.infer<typeof PaceInputSchema>;
|
package/dist/schemas.js
CHANGED
|
@@ -616,3 +616,91 @@ export const ReviewsInputSchema = z
|
|
|
616
616
|
.refine((data) => data.id !== undefined || data.title !== undefined, {
|
|
617
617
|
message: "Provide either an id or a title.",
|
|
618
618
|
});
|
|
619
|
+
// === 0.7.0 Analytics & Insight ===
|
|
620
|
+
/** Input for score calibration analysis */
|
|
621
|
+
export const CalibrationInputSchema = z.object({
|
|
622
|
+
username: usernameSchema
|
|
623
|
+
.optional()
|
|
624
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
625
|
+
type: z
|
|
626
|
+
.enum(["ANIME", "MANGA"])
|
|
627
|
+
.default("ANIME")
|
|
628
|
+
.describe("Analyze anime or manga scores"),
|
|
629
|
+
});
|
|
630
|
+
/** Input for drop pattern analysis */
|
|
631
|
+
export const DropPatternInputSchema = z.object({
|
|
632
|
+
username: usernameSchema
|
|
633
|
+
.optional()
|
|
634
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
635
|
+
type: z
|
|
636
|
+
.enum(["ANIME", "MANGA"])
|
|
637
|
+
.default("ANIME")
|
|
638
|
+
.describe("Analyze anime or manga drops"),
|
|
639
|
+
});
|
|
640
|
+
/** Input for genre evolution over time */
|
|
641
|
+
export const EvolutionInputSchema = z.object({
|
|
642
|
+
username: usernameSchema
|
|
643
|
+
.optional()
|
|
644
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
645
|
+
type: z
|
|
646
|
+
.enum(["ANIME", "MANGA"])
|
|
647
|
+
.default("ANIME")
|
|
648
|
+
.describe("Track anime or manga taste evolution"),
|
|
649
|
+
});
|
|
650
|
+
/** Input for franchise completion tracking */
|
|
651
|
+
export const CompletionistInputSchema = z.object({
|
|
652
|
+
username: usernameSchema
|
|
653
|
+
.optional()
|
|
654
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
655
|
+
type: z
|
|
656
|
+
.enum(["ANIME", "MANGA"])
|
|
657
|
+
.default("ANIME")
|
|
658
|
+
.describe("Check anime or manga franchise completion"),
|
|
659
|
+
limit: z
|
|
660
|
+
.number()
|
|
661
|
+
.int()
|
|
662
|
+
.min(1)
|
|
663
|
+
.max(20)
|
|
664
|
+
.default(10)
|
|
665
|
+
.describe("Number of franchise groups to show (default 10, max 20)"),
|
|
666
|
+
});
|
|
667
|
+
/** Input for seasonal pick-up and completion rates */
|
|
668
|
+
export const SeasonalHitRateInputSchema = z.object({
|
|
669
|
+
username: usernameSchema
|
|
670
|
+
.optional()
|
|
671
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
672
|
+
season: z
|
|
673
|
+
.enum(["WINTER", "SPRING", "SUMMER", "FALL"])
|
|
674
|
+
.optional()
|
|
675
|
+
.describe("Season to analyze. Defaults to last completed season."),
|
|
676
|
+
year: z
|
|
677
|
+
.number()
|
|
678
|
+
.int()
|
|
679
|
+
.min(2000)
|
|
680
|
+
.max(MAX_YEAR)
|
|
681
|
+
.optional()
|
|
682
|
+
.describe("Year to analyze. Defaults to current year."),
|
|
683
|
+
history: z
|
|
684
|
+
.number()
|
|
685
|
+
.int()
|
|
686
|
+
.min(1)
|
|
687
|
+
.max(8)
|
|
688
|
+
.default(4)
|
|
689
|
+
.describe("Number of past seasons to show (default 4, max 8)"),
|
|
690
|
+
});
|
|
691
|
+
/** Input for pace estimation */
|
|
692
|
+
export const PaceInputSchema = z.object({
|
|
693
|
+
username: usernameSchema
|
|
694
|
+
.optional()
|
|
695
|
+
.describe("AniList username. Falls back to configured default if not provided."),
|
|
696
|
+
mediaId: z
|
|
697
|
+
.number()
|
|
698
|
+
.int()
|
|
699
|
+
.positive()
|
|
700
|
+
.optional()
|
|
701
|
+
.describe("AniList media ID to estimate pace for. Omit for all current titles."),
|
|
702
|
+
type: z
|
|
703
|
+
.enum(["ANIME", "MANGA"])
|
|
704
|
+
.default("ANIME")
|
|
705
|
+
.describe("Estimate pace for anime or manga"),
|
|
706
|
+
});
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/** Analytics tools: scoring calibration, drop patterns, genre evolution, and more. */
|
|
2
|
+
import { anilistClient } from "../api/client.js";
|
|
3
|
+
import { BATCH_RELATIONS_QUERY } from "../api/queries.js";
|
|
4
|
+
import { computeCalibration, analyzeDrops, computeGenreEvolution, } from "../engine/analytics.js";
|
|
5
|
+
import { CalibrationInputSchema, DropPatternInputSchema, EvolutionInputSchema, CompletionistInputSchema, SeasonalHitRateInputSchema, PaceInputSchema, } from "../schemas.js";
|
|
6
|
+
import { getDefaultUsername, getTitle, resolveSeasonYear, throwToolError, dateToEpoch, } from "../utils.js";
|
|
7
|
+
// Season order for iteration
|
|
8
|
+
const SEASON_ORDER = ["WINTER", "SPRING", "SUMMER", "FALL"];
|
|
9
|
+
/** Register all analytics tools */
|
|
10
|
+
export function registerAnalyticsTools(server) {
|
|
11
|
+
// === Score Calibration ===
|
|
12
|
+
server.addTool({
|
|
13
|
+
name: "anilist_calibration",
|
|
14
|
+
description: "Score calibration analysis showing how a user rates compared to community consensus. " +
|
|
15
|
+
"Use when the user asks if they score too high or low, which genres they're harshest " +
|
|
16
|
+
"or most generous on, or how their taste compares to mainstream. " +
|
|
17
|
+
"Returns overall bias, per-genre deviation, and scoring tendency.",
|
|
18
|
+
parameters: CalibrationInputSchema,
|
|
19
|
+
annotations: {
|
|
20
|
+
title: "Score Calibration",
|
|
21
|
+
readOnlyHint: true,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
idempotentHint: true,
|
|
24
|
+
openWorldHint: true,
|
|
25
|
+
},
|
|
26
|
+
execute: async (args) => {
|
|
27
|
+
try {
|
|
28
|
+
const username = getDefaultUsername(args.username);
|
|
29
|
+
const entries = await anilistClient.fetchList(username, args.type, "COMPLETED");
|
|
30
|
+
const result = computeCalibration(entries);
|
|
31
|
+
if (result.totalScored === 0) {
|
|
32
|
+
return `${username} has no scored ${args.type.toLowerCase()} entries to calibrate.`;
|
|
33
|
+
}
|
|
34
|
+
const lines = [
|
|
35
|
+
`# Score Calibration: ${username} (${args.type.toLowerCase()})`,
|
|
36
|
+
"",
|
|
37
|
+
`Based on ${result.totalScored} scored titles.`,
|
|
38
|
+
"",
|
|
39
|
+
];
|
|
40
|
+
// Overall tendency
|
|
41
|
+
const sign = result.overallDelta >= 0 ? "+" : "";
|
|
42
|
+
lines.push(`Overall: ${sign}${result.overallDelta.toFixed(2)} vs community (${result.tendency} scorer)`);
|
|
43
|
+
// Per-genre breakdown
|
|
44
|
+
if (result.genreCalibrations.length > 0) {
|
|
45
|
+
lines.push("", "Per-genre bias (biggest deviations first):");
|
|
46
|
+
for (const g of result.genreCalibrations.slice(0, 10)) {
|
|
47
|
+
const gSign = g.delta >= 0 ? "+" : "";
|
|
48
|
+
const direction = g.delta >= 0 ? "higher" : "lower";
|
|
49
|
+
lines.push(` ${g.genre}: ${gSign}${g.delta.toFixed(2)} (you rate ${Math.abs(g.delta).toFixed(1)} ${direction} than average, ${g.count} titles)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return throwToolError(error, "computing score calibration");
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
// === Drop Pattern Analysis ===
|
|
60
|
+
server.addTool({
|
|
61
|
+
name: "anilist_drops",
|
|
62
|
+
description: "Drop pattern analysis from a user's dropped list. " +
|
|
63
|
+
"Use when the user asks why they drop shows, what patterns their drops follow, " +
|
|
64
|
+
"or which genres they abandon most. " +
|
|
65
|
+
"Returns drop rate by genre/tag, median episode at drop, and early drop percentage.",
|
|
66
|
+
parameters: DropPatternInputSchema,
|
|
67
|
+
annotations: {
|
|
68
|
+
title: "Drop Patterns",
|
|
69
|
+
readOnlyHint: true,
|
|
70
|
+
destructiveHint: false,
|
|
71
|
+
idempotentHint: true,
|
|
72
|
+
openWorldHint: true,
|
|
73
|
+
},
|
|
74
|
+
execute: async (args) => {
|
|
75
|
+
try {
|
|
76
|
+
const username = getDefaultUsername(args.username);
|
|
77
|
+
// Fetch dropped and all entries in parallel
|
|
78
|
+
const [dropped, all] = await Promise.all([
|
|
79
|
+
anilistClient.fetchList(username, args.type, "DROPPED"),
|
|
80
|
+
anilistClient.fetchList(username, args.type),
|
|
81
|
+
]);
|
|
82
|
+
if (dropped.length === 0) {
|
|
83
|
+
return `${username} hasn't dropped any ${args.type.toLowerCase()} titles.`;
|
|
84
|
+
}
|
|
85
|
+
const result = analyzeDrops(dropped, all);
|
|
86
|
+
const lines = [
|
|
87
|
+
`# Drop Patterns: ${username} (${args.type.toLowerCase()})`,
|
|
88
|
+
"",
|
|
89
|
+
`${result.totalDropped} titles dropped.`,
|
|
90
|
+
];
|
|
91
|
+
// Early drop stats
|
|
92
|
+
if (result.totalDropped > 0) {
|
|
93
|
+
const earlyPct = ((result.earlyDrops / result.totalDropped) *
|
|
94
|
+
100).toFixed(0);
|
|
95
|
+
lines.push(`${result.earlyDrops} early drops (${earlyPct}% dropped before 25% progress)`);
|
|
96
|
+
if (result.avgDropProgress > 0) {
|
|
97
|
+
lines.push(`Average drop point: ${(result.avgDropProgress * 100).toFixed(0)}% through`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Genre clusters
|
|
101
|
+
const genreClusters = result.clusters.filter((c) => c.type === "genre");
|
|
102
|
+
if (genreClusters.length > 0) {
|
|
103
|
+
lines.push("", "Drop rate by genre:");
|
|
104
|
+
for (const c of genreClusters) {
|
|
105
|
+
const pct = (c.dropRate * 100).toFixed(0);
|
|
106
|
+
const dropEp = c.medianDropPoint > 0
|
|
107
|
+
? ` (median drop at ${(c.medianDropPoint * 100).toFixed(0)}%)`
|
|
108
|
+
: "";
|
|
109
|
+
lines.push(` ${c.label}: ${pct}% drop rate (${c.dropCount}/${c.totalCount})${dropEp}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Tag clusters
|
|
113
|
+
const tagClusters = result.clusters.filter((c) => c.type === "tag");
|
|
114
|
+
if (tagClusters.length > 0) {
|
|
115
|
+
lines.push("", "Drop rate by theme:");
|
|
116
|
+
for (const c of tagClusters) {
|
|
117
|
+
const pct = (c.dropRate * 100).toFixed(0);
|
|
118
|
+
lines.push(` ${c.label}: ${pct}% drop rate (${c.dropCount}/${c.totalCount})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return throwToolError(error, "analyzing drop patterns");
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
// === Genre Evolution ===
|
|
129
|
+
server.addTool({
|
|
130
|
+
name: "anilist_evolution",
|
|
131
|
+
description: "Genre evolution analysis showing how taste has shifted over time. " +
|
|
132
|
+
"Use when the user asks how their taste has changed, what they used to watch vs now, " +
|
|
133
|
+
"or wants a timeline of their preferences. " +
|
|
134
|
+
"Returns era-by-era genre rankings and shift descriptions.",
|
|
135
|
+
parameters: EvolutionInputSchema,
|
|
136
|
+
annotations: {
|
|
137
|
+
title: "Genre Evolution",
|
|
138
|
+
readOnlyHint: true,
|
|
139
|
+
destructiveHint: false,
|
|
140
|
+
idempotentHint: true,
|
|
141
|
+
openWorldHint: true,
|
|
142
|
+
},
|
|
143
|
+
execute: async (args) => {
|
|
144
|
+
try {
|
|
145
|
+
const username = getDefaultUsername(args.username);
|
|
146
|
+
const entries = await anilistClient.fetchList(username, args.type, "COMPLETED");
|
|
147
|
+
const result = computeGenreEvolution(entries);
|
|
148
|
+
if (result.eras.length === 0) {
|
|
149
|
+
return `${username} has no dated completed ${args.type.toLowerCase()} entries to analyze.`;
|
|
150
|
+
}
|
|
151
|
+
const lines = [
|
|
152
|
+
`# Genre Evolution: ${username} (${args.type.toLowerCase()})`,
|
|
153
|
+
"",
|
|
154
|
+
];
|
|
155
|
+
for (const era of result.eras) {
|
|
156
|
+
lines.push(`${era.period} (${era.count} titles): ${era.topGenres.join(", ")}`);
|
|
157
|
+
}
|
|
158
|
+
if (result.shifts.length > 0) {
|
|
159
|
+
lines.push("", "Key shifts:");
|
|
160
|
+
for (const s of result.shifts) {
|
|
161
|
+
lines.push(` ${s}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return throwToolError(error, "analyzing genre evolution");
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
// === Completionist Stats ===
|
|
172
|
+
server.addTool({
|
|
173
|
+
name: "anilist_completionist",
|
|
174
|
+
description: "Franchise completion tracker showing progress through series with sequels. " +
|
|
175
|
+
"Use when the user asks what franchises they've started but not finished, " +
|
|
176
|
+
"their completion rate, or what's left to watch in a series. " +
|
|
177
|
+
"Returns franchise groups with completed/total counts.",
|
|
178
|
+
parameters: CompletionistInputSchema,
|
|
179
|
+
annotations: {
|
|
180
|
+
title: "Franchise Completion",
|
|
181
|
+
readOnlyHint: true,
|
|
182
|
+
destructiveHint: false,
|
|
183
|
+
idempotentHint: true,
|
|
184
|
+
openWorldHint: true,
|
|
185
|
+
},
|
|
186
|
+
execute: async (args) => {
|
|
187
|
+
try {
|
|
188
|
+
const username = getDefaultUsername(args.username);
|
|
189
|
+
// Get all entries to know which IDs the user has interacted with
|
|
190
|
+
const allEntries = await anilistClient.fetchList(username, args.type);
|
|
191
|
+
if (allEntries.length === 0) {
|
|
192
|
+
return `${username} has no ${args.type.toLowerCase()} entries.`;
|
|
193
|
+
}
|
|
194
|
+
const completedIds = new Set(allEntries
|
|
195
|
+
.filter((e) => e.status === "COMPLETED")
|
|
196
|
+
.map((e) => e.media.id));
|
|
197
|
+
const allIds = new Set(allEntries.map((e) => e.media.id));
|
|
198
|
+
// Batch-fetch relations for all media the user has
|
|
199
|
+
const relationsMap = new Map();
|
|
200
|
+
let frontier = [...allIds];
|
|
201
|
+
const maxRounds = 3;
|
|
202
|
+
for (let round = 0; round < maxRounds && frontier.length > 0; round++) {
|
|
203
|
+
// Process in chunks of 50
|
|
204
|
+
for (let i = 0; i < frontier.length; i += 50) {
|
|
205
|
+
const chunk = frontier.slice(i, i + 50);
|
|
206
|
+
const data = await anilistClient.query(BATCH_RELATIONS_QUERY, { ids: chunk }, { cache: "media" });
|
|
207
|
+
for (const media of data.Page.media) {
|
|
208
|
+
if (!relationsMap.has(media.id)) {
|
|
209
|
+
relationsMap.set(media.id, media);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Discover new IDs from relations
|
|
214
|
+
const nextFrontier = [];
|
|
215
|
+
for (const node of relationsMap.values()) {
|
|
216
|
+
for (const edge of node.relations.edges) {
|
|
217
|
+
if (!relationsMap.has(edge.node.id) &&
|
|
218
|
+
(edge.relationType === "SEQUEL" ||
|
|
219
|
+
edge.relationType === "PREQUEL") &&
|
|
220
|
+
edge.node.type === args.type) {
|
|
221
|
+
nextFrontier.push(edge.node.id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
frontier = nextFrontier;
|
|
226
|
+
}
|
|
227
|
+
// Group by franchise root (follow PREQUEL edges backward)
|
|
228
|
+
const franchises = new Map();
|
|
229
|
+
const assigned = new Set();
|
|
230
|
+
for (const id of allIds) {
|
|
231
|
+
if (assigned.has(id))
|
|
232
|
+
continue;
|
|
233
|
+
// Find root by following prequel edges
|
|
234
|
+
let rootId = id;
|
|
235
|
+
const visited = new Set();
|
|
236
|
+
while (true) {
|
|
237
|
+
visited.add(rootId);
|
|
238
|
+
const node = relationsMap.get(rootId);
|
|
239
|
+
if (!node)
|
|
240
|
+
break;
|
|
241
|
+
const prequel = node.relations.edges.find((e) => e.relationType === "PREQUEL" && !visited.has(e.node.id));
|
|
242
|
+
if (!prequel)
|
|
243
|
+
break;
|
|
244
|
+
rootId = prequel.node.id;
|
|
245
|
+
}
|
|
246
|
+
// Collect all franchise members via sequel/prequel edges
|
|
247
|
+
const members = new Set();
|
|
248
|
+
const queue = [rootId];
|
|
249
|
+
while (queue.length > 0) {
|
|
250
|
+
const current = queue.shift();
|
|
251
|
+
if (current === undefined || members.has(current))
|
|
252
|
+
continue;
|
|
253
|
+
members.add(current);
|
|
254
|
+
const node = relationsMap.get(current);
|
|
255
|
+
if (!node)
|
|
256
|
+
continue;
|
|
257
|
+
for (const edge of node.relations.edges) {
|
|
258
|
+
if ((edge.relationType === "SEQUEL" ||
|
|
259
|
+
edge.relationType === "PREQUEL") &&
|
|
260
|
+
edge.node.type === args.type &&
|
|
261
|
+
!members.has(edge.node.id)) {
|
|
262
|
+
queue.push(edge.node.id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Only track franchises with 2+ entries
|
|
267
|
+
if (members.size < 2)
|
|
268
|
+
continue;
|
|
269
|
+
// Find a good title for the franchise
|
|
270
|
+
const rootNode = relationsMap.get(rootId);
|
|
271
|
+
const title = rootNode
|
|
272
|
+
? (rootNode.title.english ?? rootNode.title.romaji ?? "Unknown")
|
|
273
|
+
: "Unknown";
|
|
274
|
+
const memberArr = [...members];
|
|
275
|
+
const completed = memberArr.filter((m) => completedIds.has(m)).length;
|
|
276
|
+
for (const m of members)
|
|
277
|
+
assigned.add(m);
|
|
278
|
+
franchises.set(rootId, {
|
|
279
|
+
title,
|
|
280
|
+
members: memberArr,
|
|
281
|
+
completed,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// Sort by largest completion gap (most to finish first)
|
|
285
|
+
const sorted = [...franchises.values()]
|
|
286
|
+
.filter((f) => f.completed > 0 && f.completed < f.members.length)
|
|
287
|
+
.sort((a, b) => b.members.length - b.completed - (a.members.length - a.completed));
|
|
288
|
+
if (sorted.length === 0) {
|
|
289
|
+
return `${username} has no partially completed franchises.`;
|
|
290
|
+
}
|
|
291
|
+
const lines = [
|
|
292
|
+
`# Franchise Completion: ${username} (${args.type.toLowerCase()})`,
|
|
293
|
+
"",
|
|
294
|
+
];
|
|
295
|
+
for (const f of sorted.slice(0, args.limit)) {
|
|
296
|
+
const remaining = f.members.length - f.completed;
|
|
297
|
+
const pct = ((f.completed / f.members.length) * 100).toFixed(0);
|
|
298
|
+
lines.push(`${f.title}: ${f.completed}/${f.members.length} (${pct}%) - ${remaining} remaining`);
|
|
299
|
+
}
|
|
300
|
+
if (sorted.length > args.limit) {
|
|
301
|
+
lines.push("", `${sorted.length - args.limit} more franchises not shown.`);
|
|
302
|
+
}
|
|
303
|
+
return lines.join("\n");
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
return throwToolError(error, "computing franchise completion");
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
// === Seasonal Hit Rate ===
|
|
311
|
+
server.addTool({
|
|
312
|
+
name: "anilist_seasonal_stats",
|
|
313
|
+
description: "Seasonal pick-up and completion rates. " +
|
|
314
|
+
"Use when the user asks about their seasonal watching habits, how many shows " +
|
|
315
|
+
"they finish vs drop each season, or their hit rate. " +
|
|
316
|
+
"Returns per-season breakdown of picked, finished, dropped, and ongoing counts.",
|
|
317
|
+
parameters: SeasonalHitRateInputSchema,
|
|
318
|
+
annotations: {
|
|
319
|
+
title: "Seasonal Hit Rate",
|
|
320
|
+
readOnlyHint: true,
|
|
321
|
+
destructiveHint: false,
|
|
322
|
+
idempotentHint: true,
|
|
323
|
+
openWorldHint: true,
|
|
324
|
+
},
|
|
325
|
+
execute: async (args) => {
|
|
326
|
+
try {
|
|
327
|
+
const username = getDefaultUsername(args.username);
|
|
328
|
+
// Fetch all entries (all statuses)
|
|
329
|
+
const entries = await anilistClient.fetchList(username, "ANIME");
|
|
330
|
+
if (entries.length === 0) {
|
|
331
|
+
return `${username} has no anime entries.`;
|
|
332
|
+
}
|
|
333
|
+
// Determine target seasons to show
|
|
334
|
+
const { season: currentSeason, year: currentYear } = resolveSeasonYear(args.season, args.year);
|
|
335
|
+
// Build list of seasons to analyze (going backward)
|
|
336
|
+
const seasons = [];
|
|
337
|
+
let s = SEASON_ORDER.indexOf(currentSeason);
|
|
338
|
+
let y = currentYear;
|
|
339
|
+
for (let i = 0; i < args.history; i++) {
|
|
340
|
+
seasons.push({ season: SEASON_ORDER[s], year: y });
|
|
341
|
+
s--;
|
|
342
|
+
if (s < 0) {
|
|
343
|
+
s = 3;
|
|
344
|
+
y--;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
seasons.reverse();
|
|
348
|
+
const lines = [`# Seasonal Hit Rate: ${username}`, ""];
|
|
349
|
+
for (const target of seasons) {
|
|
350
|
+
// Match entries by the media's season/year
|
|
351
|
+
const matching = entries.filter((e) => e.media.season === target.season &&
|
|
352
|
+
e.media.seasonYear === target.year);
|
|
353
|
+
if (matching.length === 0)
|
|
354
|
+
continue;
|
|
355
|
+
const finished = matching.filter((e) => e.status === "COMPLETED").length;
|
|
356
|
+
const dropped = matching.filter((e) => e.status === "DROPPED").length;
|
|
357
|
+
const current = matching.filter((e) => e.status === "CURRENT").length;
|
|
358
|
+
const paused = matching.filter((e) => e.status === "PAUSED").length;
|
|
359
|
+
const hitRate = ((finished / matching.length) * 100).toFixed(0);
|
|
360
|
+
const parts = [];
|
|
361
|
+
parts.push(`${finished} finished`);
|
|
362
|
+
if (dropped > 0)
|
|
363
|
+
parts.push(`${dropped} dropped`);
|
|
364
|
+
if (current > 0)
|
|
365
|
+
parts.push(`${current} watching`);
|
|
366
|
+
if (paused > 0)
|
|
367
|
+
parts.push(`${paused} paused`);
|
|
368
|
+
lines.push(`${target.season} ${target.year}: ${matching.length} picked up - ${parts.join(", ")} (${hitRate}% hit rate)`);
|
|
369
|
+
}
|
|
370
|
+
if (lines.length <= 2) {
|
|
371
|
+
return `No seasonal data found for ${username} in the requested range.`;
|
|
372
|
+
}
|
|
373
|
+
return lines.join("\n");
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
return throwToolError(error, "computing seasonal hit rate");
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
// === Pace Estimate ===
|
|
381
|
+
server.addTool({
|
|
382
|
+
name: "anilist_pace",
|
|
383
|
+
description: "Pace estimate for currently watching or reading titles. " +
|
|
384
|
+
"Use when the user asks how long it'll take to finish something, " +
|
|
385
|
+
"their watch rate, or wants a progress summary. " +
|
|
386
|
+
"Returns estimated completion date based on historical pace.",
|
|
387
|
+
parameters: PaceInputSchema,
|
|
388
|
+
annotations: {
|
|
389
|
+
title: "Pace Estimate",
|
|
390
|
+
readOnlyHint: true,
|
|
391
|
+
destructiveHint: false,
|
|
392
|
+
idempotentHint: true,
|
|
393
|
+
openWorldHint: true,
|
|
394
|
+
},
|
|
395
|
+
execute: async (args) => {
|
|
396
|
+
try {
|
|
397
|
+
const username = getDefaultUsername(args.username);
|
|
398
|
+
let entries = await anilistClient.fetchList(username, args.type, "CURRENT");
|
|
399
|
+
if (entries.length === 0) {
|
|
400
|
+
return `${username} has no current ${args.type.toLowerCase()} entries.`;
|
|
401
|
+
}
|
|
402
|
+
// Filter to specific media if requested
|
|
403
|
+
if (args.mediaId) {
|
|
404
|
+
entries = entries.filter((e) => e.media.id === args.mediaId);
|
|
405
|
+
if (entries.length === 0) {
|
|
406
|
+
return `Media ID ${args.mediaId} is not on ${username}'s current list.`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const now = Date.now() / 1000;
|
|
410
|
+
const unit = args.type === "ANIME" ? "ep" : "ch";
|
|
411
|
+
const lines = [
|
|
412
|
+
`# Pace Estimate: ${username} (${args.type.toLowerCase()})`,
|
|
413
|
+
"",
|
|
414
|
+
];
|
|
415
|
+
for (const entry of entries) {
|
|
416
|
+
const title = getTitle(entry.media.title);
|
|
417
|
+
const total = entry.media.episodes ?? entry.media.chapters ?? 0;
|
|
418
|
+
const remaining = total > 0 ? total - entry.progress : 0;
|
|
419
|
+
// Compute pace from startedAt
|
|
420
|
+
const startEpoch = dateToEpoch(entry.startedAt);
|
|
421
|
+
const weeksSinceStart = startEpoch
|
|
422
|
+
? (now - startEpoch) / (7 * 24 * 3600)
|
|
423
|
+
: 0;
|
|
424
|
+
let rateLine = "";
|
|
425
|
+
let estimateLine = "";
|
|
426
|
+
if (weeksSinceStart >= 1 && entry.progress > 0) {
|
|
427
|
+
const perWeek = entry.progress / weeksSinceStart;
|
|
428
|
+
rateLine = `${perWeek.toFixed(1)} ${unit}/week`;
|
|
429
|
+
if (remaining > 0 && perWeek > 0) {
|
|
430
|
+
const weeksLeft = remaining / perWeek;
|
|
431
|
+
const finishEpoch = now + weeksLeft * 7 * 24 * 3600;
|
|
432
|
+
const finishDate = new Date(finishEpoch * 1000);
|
|
433
|
+
const dateStr = finishDate.toISOString().split("T")[0];
|
|
434
|
+
estimateLine = `~${Math.ceil(weeksLeft)} weeks (est. ${dateStr})`;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Progress string
|
|
438
|
+
const progressStr = total > 0
|
|
439
|
+
? `${entry.progress}/${total} ${unit}`
|
|
440
|
+
: `${entry.progress} ${unit}`;
|
|
441
|
+
const parts = [progressStr];
|
|
442
|
+
if (rateLine)
|
|
443
|
+
parts.push(rateLine);
|
|
444
|
+
if (estimateLine)
|
|
445
|
+
parts.push(estimateLine);
|
|
446
|
+
lines.push(`${title}: ${parts.join(" - ")}`);
|
|
447
|
+
}
|
|
448
|
+
return lines.join("\n");
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
return throwToolError(error, "estimating pace");
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Formatting and resolution helpers. */
|
|
2
|
-
import type { AniListMedia, ScoreFormat } from "./types.js";
|
|
2
|
+
import type { AniListDate, AniListMedia, ScoreFormat } from "./types.js";
|
|
3
3
|
/** Best available title, respecting ANILIST_TITLE_LANGUAGE preference */
|
|
4
4
|
export declare function getTitle(title: AniListMedia["title"]): string;
|
|
5
5
|
/** Whether NSFW/adult content is enabled via env var (default: false) */
|
|
@@ -23,5 +23,7 @@ export declare function resolveSeasonYear(season?: string, year?: number): {
|
|
|
23
23
|
season: string;
|
|
24
24
|
year: number;
|
|
25
25
|
};
|
|
26
|
+
/** Convert an AniListDate to Unix epoch seconds, or null if year is missing */
|
|
27
|
+
export declare function dateToEpoch(date: AniListDate): number | null;
|
|
26
28
|
/** Display a normalized 0-10 score in the user's preferred format */
|
|
27
29
|
export declare function formatScore(score10: number, format: ScoreFormat): string;
|
package/dist/utils.js
CHANGED
|
@@ -153,6 +153,14 @@ export function resolveSeasonYear(season, year) {
|
|
|
153
153
|
: "FALL";
|
|
154
154
|
return { season: currentSeason, year: currentYear };
|
|
155
155
|
}
|
|
156
|
+
/** Convert an AniListDate to Unix epoch seconds, or null if year is missing */
|
|
157
|
+
export function dateToEpoch(date) {
|
|
158
|
+
if (date.year == null)
|
|
159
|
+
return null;
|
|
160
|
+
const month = date.month ?? 1;
|
|
161
|
+
const day = date.day ?? 1;
|
|
162
|
+
return new Date(date.year, month - 1, day).getTime() / 1000;
|
|
163
|
+
}
|
|
156
164
|
/** Display a normalized 0-10 score in the user's preferred format */
|
|
157
165
|
export function formatScore(score10, format) {
|
|
158
166
|
if (score10 <= 0)
|
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "ani-mcp",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"display_name": "AniList MCP",
|
|
6
6
|
"description": "A smart MCP server for AniList that gets your anime/manga taste - not just API calls.",
|
|
7
7
|
"author": {
|
|
@@ -146,6 +146,24 @@
|
|
|
146
146
|
},
|
|
147
147
|
{
|
|
148
148
|
"name": "anilist_delete_from_list"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "anilist_calibration"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"name": "anilist_drops"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "anilist_evolution"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"name": "anilist_completionist"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"name": "anilist_seasonal_stats"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "anilist_pace"
|
|
149
167
|
}
|
|
150
168
|
]
|
|
151
169
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ani-mcp",
|
|
3
3
|
"mcpName": "io.github.gavxm/ani-mcp",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "A smart [MCP](https://modelcontextprotocol.io) server for [AniList](https://anilist.co) that gets your anime/manga taste - not just API calls.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/gavxm/ani-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.7.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ani-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.7.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|