diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..f1a654659520aa57afb62b40d08dbe67cf62971e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +.env +.gitignore +.dockerignore +logs +tmp +coverage diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index a6344aac8c09253b3b630fb776ae94478aa0275b..0000000000000000000000000000000000000000 --- a/.gitattributes +++ /dev/null @@ -1,35 +0,0 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b66d33b78914658313d82a5f0e658cf01876b8c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# local env files +.env*.local +.env* +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d59b70ff307eb82f9f9fb3c7163760e0893660c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Stage 1: Build +FROM node:20-alpine AS builder + +# Install pnpm +RUN npm install -g pnpm + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY . . + +# Build the application +RUN pnpm run build + +# Stage 2: Production +FROM node:20-alpine + +# Install pnpm +RUN npm install -g pnpm + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ + +# Install only production dependencies +RUN pnpm install --prod --frozen-lockfile + +# Copy built artifacts from builder stage +COPY --from=builder /app/dist ./dist + +# Expose the application port +EXPOSE 3000 + +# Start the application +CMD ["pnpm", "start"] diff --git a/README.md b/README.md index a121957133d547c551abf4bce7852c1c5fe3da9d..d435c7e885d47cb95337166888ebfea4574aad6e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,587 @@ +# Vortex CP + +A high-speed, vertical-slice aggregator for competitive programming metrics. Vortex CP unifies data from LeetCode, Codeforces, CodeChef, AtCoder, and GeeksforGeeks into a single, modular API layer. Built for modern web applications and AI agents via the **Model Context Protocol (MCP)**. + +--- + +## Architecture + +Vortex follows a **strict vertical slice architecture**. Each competitive programming platform is an isolated, self-contained plugin that flows through a consistent request pipeline. + +### Vortex Flow Diagram + +```mermaid +graph TD + A[HTTP Request] --> B[Fastify Router] + B --> C{Route Match} + + C -->|/api/v1/leetcode| D1[LeetCode Handler] + C -->|/api/v1/codeforces| D2[Codeforces Handler] + C -->|/api/v1/codechef| D3[CodeChef Handler] + C -->|/api/v1/atcoder| D4[AtCoder Handler] + C -->|/api/v1/gfg| D5[GFG Handler] + C -->|/api/v1/ratings| D6[Aggregator Handler] + + D1 --> E1[LeetCode Service] + D2 --> E2[Codeforces Service] + D3 --> E3[CodeChef Service] + D4 --> E4[AtCoder Service] + D5 --> E5[GFG Service] + D6 --> E6[Aggregator Service] + + E1 --> F1[LeetCode Provider] + E2 --> F2[Codeforces Provider] + E3 --> F3[CodeChef Provider] + E4 --> F4[AtCoder Provider] + E5 --> F5[GFG Provider] + E6 --> F6[Multi-Platform Provider] + + F1 --> G1[LeetCode GraphQL API] + F2 --> G2[Codeforces REST API] + F3 --> G3[CodeChef REST API] + F4 --> G4[AtCoder REST API] + F5 --> G5[GFG Scraper] + + G1 --> H[Response] + G2 --> H + G3 --> H + G4 --> H + G5 --> H + + H --> I[JSON Response] + + style D1 fill:#e3f2fd + style D2 fill:#e3f2fd + style D3 fill:#e3f2fd + style D4 fill:#e3f2fd + style D5 fill:#e3f2fd + style D6 fill:#fff3e0 + style E6 fill:#fff3e0 + style F6 fill:#fff3e0 +``` + +### Vertical Slice Philosophy + +Each platform module (`src/modules/{platform}/`) is a **complete vertical slice**: + +| Layer | Responsibility | Dependencies | +|-------|---------------|--------------| +| **routes.ts** | Fastify plugin registration, OpenAPI schemas, endpoint definitions | None (Entry Point) | +| **handlers.ts** | HTTP request/response handling, parameter extraction, validation | Service layer | +| **service.ts** | Business logic, data transformation, error handling | Provider layer | +| **provider.ts** | External API integration, HTTP client configuration | External APIs only | +| **types.ts** | TypeScript interfaces for domain models | None | +| **schemas.ts** | JSON Schema for request/response validation | None | + +**Key Constraint:** Each layer can only depend on layers below it. No cross-module dependencies are allowed except through the aggregator service. + --- -title: Vortex -emoji: 🐠 -colorFrom: blue -colorTo: yellow -sdk: docker -pinned: false + +## Project Structure + +``` +src/ +├── app.ts # Fastify app configuration & plugin registration +├── server.ts # HTTP server bootstrap +├── config/ +│ └── env.ts # Environment variable validation +├── shared/ +│ ├── middlewares/ +│ │ └── validate.ts # Global validation middleware +│ └── utils/ +│ ├── http-client.ts # Axios instance with timeout & retry +│ └── timeout.ts # Request timeout utilities +├── types/ +│ ├── api.ts # Common API response types +│ └── fastify.ts # Fastify type augmentation +└── modules/ + ├── leetcode/ # LeetCode vertical slice + ├── codeforces/ # Codeforces vertical slice + ├── codechef/ # CodeChef vertical slice + ├── atcoder/ # AtCoder vertical slice + ├── gfg/ # GeeksforGeeks vertical slice + ├── ratings/ # Cross-platform aggregator + └── mcp/ # Model Context Protocol server +``` + --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +## Installation + +### Prerequisites + +- **Node.js** 18+ (LTS recommended) +- **pnpm** 8+ (install via `npm i -g pnpm`) + +### Setup + +```bash +# Clone repository +git clone https://github.com/Anujjoshi3105/vortex.git +cd vortex + +# Install dependencies +pnpm install + +# Configure environment +cp .env.example .env +# Edit .env with your configuration +``` + +#### `.env.example` + +```env +# Server Configuration +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=development + +# Security (optional) +AUTH_SECRET=your_auth_secret_here +``` + +### Development + +```bash +# Start dev server with hot-reload +pnpm dev +``` + +Server starts at `http://localhost:3000` + +API documentation: `http://localhost:3000/docs` + +### Production Build + +```bash +# Compile TypeScript +pnpm build + +# Start production server +pnpm start +``` + +### MCP Inspector (AI Tool Testing) + +```bash +# Launch MCP Inspector UI +pnpm inspect +``` + +Opens at `http://localhost:6274` for interactive MCP tool testing. + +--- + +## API Reference + +### Platform-Specific Endpoints + +| Platform | Endpoint | Description | +|----------|----------|-------------| +| **LeetCode** | `GET /api/v1/leetcode/rating?username={user}` | User contest rating & ranking | +| | `GET /api/v1/leetcode/contest-ranking?username={user}` | Contest participation history | +| | `GET /api/v1/leetcode/daily-problem` | Today's daily challenge | +| | `GET /api/v1/leetcode/contests` | Upcoming contests | +| **Codeforces** | `GET /api/v1/codeforces/rating?username={user}` | Current rating & rank | +| | `GET /api/v1/codeforces/contest-history?username={user}` | Rating change graph | +| | `GET /api/v1/codeforces/status?username={user}&from=1&count=10` | Recent submissions | +| | `GET /api/v1/codeforces/solved-problems?username={user}` | Unique solved problems | +| **CodeChef** | `GET /api/v1/codechef/rating?username={user}` | Current rating & stars | +| **AtCoder** | `GET /api/v1/atcoder/rating?username={user}` | Current rating & color | +| **GeeksforGeeks** | `GET /api/v1/gfg/rating?username={user}` | Overall score & rank | + +### Aggregator Endpoint + +```http +GET /api/v1/ratings?username={user} +``` + +Returns unified ratings from **all platforms** in a single response: + +```json +{ + "username": "tourist", + "platforms": { + "leetcode": { "rating": 3200, "rank": "Knight", ... }, + "codeforces": { "rating": 3821, "rank": "Legendary Grandmaster", ... }, + "codechef": { "rating": 2800, "stars": "7★", ... }, + "atcoder": { "rating": 3817, "color": "red", ... }, + "gfg": { "score": 9500, "rank": 1, ... } + } +} +``` + +### Health Check + +```http +GET /health +``` + +Returns `{ "status": "ok" }` for uptime monitoring. + +--- + +## Model Context Protocol (MCP) + +Vortex exposes its functionality as **MCP tools** for AI agents (e.g., Claude Desktop, LangChain agents). + +### Connecting to Claude Desktop + +Add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "vortex-cp": { + "command": "node", + "args": [ + "path/to/vortex/dist/server.js" + ], + "env": { + "PORT": "3000" + } + } + } +} +``` + +Restart Claude Desktop. + +### MCP Endpoints (HTTP/SSE) + +- **SSE Transport**: `GET /mcp/sse` +- **Message Handler**: `POST /mcp/messages?sessionId={id}` + +Use the MCP Inspector (`pnpm inspect`) to test tools interactively before integrating with agents. + +--- + +## Contributors Guide + +### Adding a New Platform + +To maintain **vertical slice integrity**, follow this checklist when adding a new platform (e.g., HackerRank): + +#### 1. Create Module Directory + +```bash +mkdir -p src/modules/hackerrank +cd src/modules/hackerrank +touch index.ts routes.ts handlers.ts service.ts provider.ts types.ts schemas.ts +``` + +#### 2. Define Types (`types.ts`) + +```typescript +export interface HackerRankRating { + username: string; + rating: number; + rank: string; + solvedCount: number; +} +``` + +#### 3. Implement Provider (`provider.ts`) + +**Responsibility:** External API integration only. No business logic. + +```typescript +import axios from 'axios'; +import { HackerRankRating } from './types'; + +export async function fetchUserRating(username: string): Promise { + const { data } = await axios.get(`https://api.hackerrank.com/users/${username}`); + return { + username: data.username, + rating: data.rating, + rank: data.rank, + solvedCount: data.challenges_solved + }; +} +``` + +#### 4. Implement Service (`service.ts`) + +**Responsibility:** Business logic, data transformation, error handling. + +```typescript +import * as provider from './provider'; +import { HackerRankRating } from './types'; + +export async function getUserRating(username: string): Promise { + if (!username || username.length < 3) { + throw new Error('Invalid username'); + } + + const data = await provider.fetchUserRating(username); + + // Apply business rules (e.g., normalize rank) + return { + ...data, + rank: data.rank.toUpperCase() + }; +} +``` + +#### 5. Implement Handlers (`handlers.ts`) + +**Responsibility:** HTTP request/response handling, parameter extraction. + +```typescript +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from './service'; + +interface RatingQuery { + username: string; +} + +export async function getUserRatingHandler( + request: FastifyRequest<{ Querystring: RatingQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + + try { + const data = await service.getUserRating(username); + reply.send({ success: true, data }); + } catch (error) { + reply.status(500).send({ success: false, error: error.message }); + } +} +``` + +#### 6. Define Schemas (`schemas.ts`) + +**Responsibility:** OpenAPI/JSON Schema for validation and documentation. + +```typescript +export const ratingQuerySchema = { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string', minLength: 3 } + } +}; + +export const ratingResponseSchema = { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + username: { type: 'string' }, + rating: { type: 'number' }, + rank: { type: 'string' }, + solvedCount: { type: 'number' } + } + } + } +}; +``` + +#### 7. Create Routes Plugin (`routes.ts`) + +**Responsibility:** Fastify plugin registration, OpenAPI tags. + +```typescript +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from './handlers'; +import * as schemas from './schemas'; + +export const hackerrankPlugin: FastifyPluginAsync = async (fastify) => { + fastify.get('/rating', { + schema: { + tags: ['HackerRank'], + description: 'Get HackerRank user rating', + querystring: schemas.ratingQuerySchema, + response: { 200: schemas.ratingResponseSchema } + } + }, handlers.getUserRatingHandler); +}; + +export default hackerrankPlugin; +``` + +#### 8. Barrel Export (`index.ts`) + +```typescript +export { hackerrankPlugin } from './routes'; +export * from './types'; +``` + +#### 9. Register in App (`src/app.ts`) + +```typescript +import { hackerrankPlugin } from './modules/hackerrank'; + +// Inside buildApp() +await fastify.register(hackerrankPlugin, { prefix: '/api/v1/hackerrank' }); +``` + +#### 10. Update OpenAPI Tags (`src/app.ts`) + +```typescript +tags: [ + // ... existing tags + { name: 'HackerRank', description: 'HackerRank platform integration' } +] +``` + +**Result:** Your new platform is now a self-contained vertical slice with zero coupling to other modules. + +--- + +## System Auditor Review: Future Resilience Checklist + +The following improvements are recommended for production-grade deployments at scale. + +### Resiliency + +| Feature | Status | Priority | Effort | +|---------|--------|----------|--------| +| **Circuit Breaker** | ❌ Not Implemented | High | Medium | +| └─ Prevent cascading failures when external APIs are down | | | | +| └─ Recommended: [opossum](https://www.npmjs.com/package/opossum) | | | | +| **Retry Logic** | ⚠️ Partial (Axios defaults) | Medium | Low | +| └─ Exponential backoff for transient failures | | | | +| └─ Recommended: [axios-retry](https://www.npmjs.com/package/axios-retry) | | | | +| **Fallback Responses** | ❌ Not Implemented | Medium | Low | +| └─ Return cached/stale data when APIs are unreachable | | | | + +### Performance + +| Feature | Status | Priority | Effort | +|---------|--------|----------|--------| +| **Redis Caching** | ❌ Not Implemented | High | Medium | +| └─ Cache platform APIs (TTL: 5-15 minutes) | | | | +| └─ Recommended: [@fastify/redis](https://github.com/fastify/fastify-redis) | | | | +| **Response Compression** | ❌ Not Implemented | Medium | Low | +| └─ Compress JSON responses > 1KB | | | | +| └─ Recommended: [@fastify/compress](https://github.com/fastify/fastify-compress) | | | | +| **Request Timeouts** | ✅ Implemented | - | - | +| └─ Per-provider timeout configuration exists | | | | + +### Security + +| Feature | Status | Priority | Effort | +|---------|--------|----------|--------| +| **Rate Limiting** | ❌ Not Implemented | High | Low | +| └─ Prevent abuse (e.g., 100 req/min per IP) | | | | +| └─ Recommended: [@fastify/rate-limit](https://github.com/fastify/fastify-rate-limit) | | | | +| **API Key Middleware** | ⚠️ Partial (AUTH_SECRET unused) | Medium | Low | +| └─ Optional authentication for public deployments | | | | +| **Input Sanitization** | ✅ Implemented | - | - | +| └─ JSON Schema validation active on all endpoints | | | | +| **CORS Restrictions** | ⚠️ Too Permissive | Medium | Low | +| └─ Currently allows `origin: '*'` - restrict in production | | | | + +### Observability + +| Feature | Status | Priority | Effort | +|---------|--------|----------|--------| +| **Structured Logging** | ⚠️ Basic (Fastify Logger) | High | Medium | +| └─ Migrate to [Pino](https://github.com/pinojs/pino) with JSON output | | | | +| └─ Add request IDs, trace context | | | | +| **OpenTelemetry** | ❌ Not Implemented | Medium | High | +| └─ Distributed tracing for multi-provider requests | | | | +| └─ Recommended: [@opentelemetry/auto-instrumentations-node](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node) | | | | +| **Metrics Endpoint** | ❌ Not Implemented | Medium | Medium | +| └─ Expose Prometheus metrics (`/metrics`) | | | | +| └─ Track: request latency, error rates, cache hit rates | | | | + +### Implementation Roadmap + +**Phase 1: Resiliency** (Sprint 1-2) +1. Add circuit breaker pattern to all providers +2. Implement Redis caching layer +3. Add rate limiting middleware + +**Phase 2: Security** (Sprint 3) +1. Restrict CORS to whitelisted origins +2. Implement API key authentication (optional) +3. Add request/response sanitization audit + +**Phase 3: Observability** (Sprint 4-5) +1. Migrate to Pino structured logging +2. Add OpenTelemetry instrumentation +3. Create Prometheus metrics endpoint +4. Build Grafana dashboards + +--- + +## Testing + +### Manual Testing + +```bash +# Test single platform +curl "http://localhost:3000/api/v1/leetcode/rating?username=tourist" + +# Test aggregator +curl "http://localhost:3000/api/v1/ratings?username=tourist" +``` + +### MCP Tool Testing + +```bash +# Launch MCP Inspector +pnpm inspect + +# Test in MCP Inspector UI at http://localhost:6274 +# Select tool: get_leetcode_user_rating +# Input: { "username": "tourist" } +``` + +--- + +## Technology Stack + +| Category | Package | Purpose | +|----------|---------|---------| +| **Runtime** | Node.js 18+ | JavaScript runtime | +| **Framework** | Fastify 5.x | High-performance HTTP server | +| **Language** | TypeScript 5.x | Type-safe development | +| **HTTP Client** | Axios 1.x | External API requests | +| **Validation** | Zod 4.x | Runtime type validation | +| **Scraping** | Cheerio 1.x | HTML parsing (GFG) | +| **Documentation** | @fastify/swagger | OpenAPI generation | +| **AI Protocol** | @modelcontextprotocol/sdk | MCP server implementation | +| **Package Manager** | pnpm 8+ | Fast, disk-efficient installs | + +--- + +## Contributing + +### Guidelines + +1. **Maintain Vertical Slices**: New features must follow the handler → service → provider pattern. +2. **Type Everything**: All functions must have explicit TypeScript types. +3. **Schema Validation**: Add JSON Schema for all new endpoints. +4. **No Cross-Module Imports**: Modules cannot import from other platform modules (use the aggregator pattern). +5. **Update OpenAPI**: Add tags and descriptions for all new routes. + +### Pull Request Checklist + +- [ ] New vertical slice follows project structure +- [ ] TypeScript types defined in `types.ts` +- [ ] JSON Schema validation in `schemas.ts` +- [ ] OpenAPI documentation updated +- [ ] No cross-module dependencies +- [ ] Tested via Swagger UI and MCP Inspector + +--- + +## License + +**ISC** - See [LICENSE](LICENSE) for details. + +--- + +## Project Status + +**Current Version:** 1.0.0 +**Deployment:** Development (not production-ready - see Auditor Review) +**Maintained By:** [@Anujjoshi3105](https://github.com/Anujjoshi3105) + +For issues or feature requests, open a GitHub issue at [Anujjoshi3105/vortex](https://github.com/Anujjoshi3105/vortex). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..15910f71cdefc519208ff5d99b862ecb408d37b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + api: + build: . + ports: + - "${PORT:-3000}:${PORT:-3000}" + env_file: + - .env + environment: + - NODE_ENV=production + restart: unless-stopped + healthcheck: + test: [ "CMD", "wget", "-qO-", "http://localhost:${PORT:-3000}/health" ] + interval: 30s + timeout: 10s + retries: 3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..461236d066f7668a3ba0a82d6ea49a3b27bc4002 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,67 @@ +# Architecture Design + +## Overview + +Vortex is built using a **Feature-Based Plugin Architecture** with **Vertical Slices**. This design ensures that each platform integration (LeetCode, Codeforces, etc.) is self-contained, highly modular, and easy to maintain. + +## Modular Structure + +Each platform and the AI interface is implemented as a Fastify plugin located in `src/modules/{module_name}/`. This allows for: +- **Isolation**: Changes in one platform don't affect others. +- **Scalability**: Easy to add or remove platforms. +- **Consistency**: Every module follows the same structure and design patterns. +- **AI Integration**: Dedicated MCP module for real-time tool access for AI agents. + + +## Vertical Slices + +Unlike traditional horizontal layering (where all controllers are in one folder, all services in another), we use vertical slices. All code related to a specific feature (like LeetCode) lives in one directory. + +### Layered Responsibilities + +Inside each module, we follow a strict layering pattern: + +1. **Plugin Entry (`index.ts`)**: Exports the Fastify plugin. +2. **Routes (`routes.ts`)**: Defines endpoints, registers schemas, and assigns handlers. +3. **Handlers (`handlers/`)**: Extracts data from requests (params, query, body), calls services, and sends responses. +4. **Services (`services/`)**: Orchestrates business logic. Pure TypeScript, no Fastify dependencies. +5. **Provider (`provider.ts`)**: Handles external communication (HTTP, GraphQL, Scraping). +6. **Schemas (`schemas.ts`)**: JSON schemas for request/response validation (also used for Swagger). +7. **Types (`types.ts`)**: TypeScript interfaces for data structures. + + ### MCP Module Structure + + The `src/modules/mcp/` module follows a slightly different structure to support the Model Context Protocol: + - **`tools/`**: Implementation of AI-callable tools. + - **`prompts/`**: Pre-defined prompts for LLM guidance. + - **`resources/`**: Static or dynamic data resources exposed to agents. + - **`index.ts`**: Plugin registration using `fastify-mcp`. + + + +## Data Flow + +```mermaid +graph TD + User([User]) -->|HTTP Request| Server[Fastify Server] + Server -->|Routes| Handler[Handler Layer] + Handler -->|Params/Query| Service[Service Layer] + Service -->|Business Logic| Provider[Provider Layer] + Provider -->|Fetch| ExternalAPI[(External API / Web)] + ExternalAPI -->|Data| Provider + Provider -->|Raw Data| Service + Service -->|Model/Format| Handler + Handler -->|JSON Response| User +``` + +## Shared Infrastructure + +- **`src/shared/`**: Common utilities, midlewares, and HTTP clients used across modules. +- **`src/config/`**: Environment variable management using `fastify-env`. +- **`src/types/`**: Global type definitions and Fastify type augmentation. + +## Performance & Validation + +- **JSON Schema**: Every route uses high-performance JSON schema validation. +- **Serialization**: Fastify uses `fast-json-stringify` for lightning-fast responses based on our schemas. +- **TypeScript**: Full end-to-end type safety from provider to handler. diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..2359a9f2670438b3a91c88a754e64aa0b4205452 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "vortex", + "version": "1.0.0", + "main": "dist/server.js", + "scripts": { + "dev": "nodemon src/server.ts", + "start": "node dist/server.js", + "build": "tsc", + "inspect": "npx @modelcontextprotocol/inspector" + }, + "author": "", + "license": "ISC", + "description": "API for contest-related operations", + "dependencies": { + "@fastify/cors": "^11.2.0", + "@fastify/swagger": "^9.6.1", + "@fastify/swagger-ui": "^5.2.4", + "@modelcontextprotocol/sdk": "^1.25.3", + "axios": "^1.7.9", + "cheerio": "^1.0.0", + "dotenv": "^16.4.7", + "fastify": "^5.7.2", + "fastify-mcp": "^2.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "nodemon": "^3.1.7", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4071ad988bd8f4b6ddf2b627865fb0e5390cc095 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1936 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + '@fastify/env': + specifier: ^5.0.3 + version: 5.0.3 + '@fastify/formbody': + specifier: ^8.0.2 + version: 8.0.2 + '@fastify/swagger': + specifier: ^9.6.1 + version: 9.6.1 + '@fastify/swagger-ui': + specifier: ^5.2.4 + version: 5.2.4 + '@modelcontextprotocol/sdk': + specifier: ^1.25.3 + version: 1.25.3(hono@4.11.6)(zod@4.3.6) + axios: + specifier: ^1.7.9 + version: 1.13.3 + cheerio: + specifier: ^1.0.0 + version: 1.2.0 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + fastify: + specifier: ^5.7.2 + version: 5.7.2 + fastify-mcp: + specifier: ^2.1.0 + version: 2.1.0(hono@4.11.6)(zod@4.3.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^25.0.10 + version: 25.0.10 + nodemon: + specifier: ^3.1.7 + version: 3.1.11 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.0.10)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/env@5.0.3': + resolution: {integrity: sha512-VqXKcw+keaZaCry9dDtphDQy6l+B1UOodk4q57NdIK/tjZsPMYEBTXjEDiZCAiD9KaGJXbJOMgYdgejU1iD0jA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.0.0': + resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + + '@fastify/swagger-ui@5.2.4': + resolution: {integrity: sha512-Maw8OYPUDxlOzKQd3VMv7T/fmjf2h6BWR3XHkhk3dD3rIfzO7C/UPnzGuTpOGMqw1HCUnctADBbeTNAzAwzUqA==} + + '@fastify/swagger@9.6.1': + resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==} + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@modelcontextprotocol/sdk@1.25.3': + resolution: {integrity: sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/node@25.0.10': + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + + axios@1.13.3: + resolution: {integrity: sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-schema@6.1.0: + resolution: {integrity: sha512-TWtYV2jKe7bd/19kzvNGa8GRRrSwmIMarhcWBzuZYPbHtdlUdjYhnaFvxrO4+GvcwF10sEeVGzf9b/wqLIyf9A==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.2.0: + resolution: {integrity: sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-mcp@2.1.0: + resolution: {integrity: sha512-nx1Es7kEqzYe3COWwqdzQbtjgGJVKXFow6pd6rKKsByyXANdJAkEXAXeIJ+ox/vhGD26X7yX6pDDGmaezkBy6g==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.7.2: + resolution: {integrity: sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.4.0: + resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} + engines: {node: '>=20'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.11.6: + resolution: {integrity: sha512-ofIiiHyl34SV6AuhE3YT2mhO5HRWokce+eUYE82TsP6z0/H3JeJcjVWEMSIAiw2QkjDOEpES/lYsg8eEbsLtdw==} + engines: {node: '>=16.9.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + engines: {node: 20 || >=22} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.0: + resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} + hasBin: true + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.19.1: + resolution: {integrity: sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==} + engines: {node: '>=20.18.1'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/env@5.0.3': + dependencies: + env-schema: 6.1.0 + fastify-plugin: 5.1.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.2.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.0.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.0.1 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.0 + + '@fastify/swagger-ui@5.2.4': + dependencies: + '@fastify/static': 9.0.0 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + + '@fastify/swagger@9.6.1': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color + + '@hono/node-server@1.19.9(hono@4.11.6)': + dependencies: + hono: 4.11.6 + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/ms@2.0.2': {} + + '@modelcontextprotocol/sdk@1.25.3(hono@4.11.6)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.6) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - hono + - supports-color + + '@pinojs/redact@0.4.0': {} + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/node@25.0.10': + dependencies: + undici-types: 7.16.0 + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@4.1.3: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios@1.13.3: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.19.1 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + diff@4.0.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv-expand@10.0.0: {} + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-schema@6.1.0: + dependencies: + ajv: 8.17.1 + dotenv: 17.2.3 + dotenv-expand: 10.0.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + express-rate-limit@7.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.2.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-mcp@2.1.0(hono@4.11.6)(zod@4.3.6): + dependencies: + '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.6)(zod@4.3.6) + fastify: 5.7.2 + transitivePeerDependencies: + - '@cfworker/json-schema' + - hono + - supports-color + - zod + + fastify-plugin@5.1.0: {} + + fastify@5.7.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.2.0 + find-my-way: 9.4.0 + light-my-request: 6.6.0 + pino: 10.3.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-my-way@9.4.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.11.6: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.3.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.1.3: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lru-cache@11.2.5: {} + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@3.0.0: {} + + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openapi-types@12.1.3: {} + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.5 + minipass: 7.1.2 + + path-to-regexp@8.3.0: {} + + picomatch@2.3.1: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 4.0.0 + + pkce-challenge@5.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pstree.remy@1.1.8: {} + + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + real-require@0.2.0: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.3: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.3 + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + statuses@2.0.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + ts-node@10.9.2(@types/node@25.0.10)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.0.10 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + undici@7.19.1: {} + + unpipe@1.0.0: {} + + v8-compile-cache-lib@3.0.1: {} + + vary@1.1.2: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + yaml@2.8.2: {} + + yn@3.1.1: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c8de917afc972a46f88df911da236f6a69d4e4e --- /dev/null +++ b/src/app.ts @@ -0,0 +1,99 @@ +import Fastify, { FastifyRequest, FastifyReply } from "fastify"; +import cors from "@fastify/cors"; +import swagger from "@fastify/swagger"; +import swaggerUi from "@fastify/swagger-ui"; +import config from "./config/env"; + +import { leetcodePlugin } from "./modules/leetcode"; +import { codeforcesPlugin } from "./modules/codeforces"; +import { codechefPlugin } from "./modules/codechef"; +import { atcoderPlugin } from "./modules/atcoder"; +import { gfgPlugin } from "./modules/gfg"; +import { ratingsPlugin } from "./modules/ratings"; +import { mcpPlugin } from "./modules/mcp"; + +export async function buildApp() { + const fastify = Fastify({ + logger: true, + }); + + fastify.setErrorHandler((error: any, request, reply) => { + fastify.log.error(error); + const statusCode = error.statusCode || 500; + reply.status(statusCode).send({ + success: false, + error: error.name || 'InternalServerError', + message: error.message || 'An unexpected error occurred', + }); + }); + + await fastify.register(cors, { + exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], + origin: '*', + }); + + await fastify.register(swagger, { + openapi: { + info: { + title: "Vortex", + description: "A high-performance modular API to fetch competitive programming contest ratings and user statistics from platforms like LeetCode, Codeforces, CodeChef, and more. Built with Fastify and TypeScript.", + version: "1.0.0", + contact: { + name: "GitHub", + url: "https://github.com/Anujjoshi3105/vortex", + }, + license: { + name: "ISC", + url: "https://opensource.org/licenses/ISC", + }, + }, + servers: [ + { + url: `http://localhost:${config.port}`, + description: 'Development server', + }, + ], + tags: [ + { name: 'Default', description: 'General server health and infrastructure endpoints' }, + { name: 'MCP', description: 'Model Context Protocol endpoints for AI agent integration' }, + { name: 'Ratings', description: 'Cross-platform rating aggregation and comparison' }, + { name: 'LeetCode - User', description: 'Fetch user profiles, badges, solved statistics, and submission history' }, + { name: 'LeetCode - Contests', description: 'Access contest rankings, history, and upcoming competition data' }, + { name: 'LeetCode - Problems', description: 'Retrieve daily challenges, problem details, and official solutions' }, + { name: 'LeetCode - Discussion', description: 'Explore trending topics and community comments' }, + { name: 'Codeforces - User', description: 'Fetch user profiles, ratings, contest history, and blogs' }, + { name: 'Codeforces - Contests', description: 'Access contest standings, hacks, and rating changes' }, + { name: 'Codeforces - Problems', description: 'Retrieve problemset and recent platform submissions' }, + { name: 'Codeforces - Blog', description: 'Explore blog entries and community comments' }, + { name: 'CodeChef', description: 'CodeChef platform integration' }, + { name: 'AtCoder', description: 'AtCoder platform integration' }, + { name: 'GFG', description: 'GeeksforGeeks platform integration for user profiles, submissions, posts, and contest leaderboards' }, + ], + }, + }); + + await fastify.register(swaggerUi, { + routePrefix: "/docs", + uiConfig: { + docExpansion: 'list', + deepLinking: true, + filter: true, + }, + staticCSP: true, + transformStaticCSP: (header) => header, + }); + + fastify.get("/health", { schema: { tags: ['Default'] } }, async (request: FastifyRequest, reply: FastifyReply) => { + return { status: "ok" }; + }); + await fastify.register(mcpPlugin); + await fastify.register(ratingsPlugin, { prefix: "/api/v1/ratings" }); + await fastify.register(leetcodePlugin, { prefix: "/api/v1/leetcode" }); + await fastify.register(codeforcesPlugin, { prefix: "/api/v1/codeforces" }); + await fastify.register(codechefPlugin, { prefix: "/api/v1/codechef" }); + await fastify.register(atcoderPlugin, { prefix: "/api/v1/atcoder" }); + await fastify.register(gfgPlugin, { prefix: "/api/v1/gfg" }); + + + return fastify; +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6ef19e2ad3f8648040f088fc5981ecac9813439 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,11 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +export const config = { + port: Number(process.env.PORT) || 3000, + host: process.env.HOST || '0.0.0.0', + nodeEnv: process.env.NODE_ENV || 'development', +} as const; + +export default config; diff --git a/src/modules/atcoder/constants.ts b/src/modules/atcoder/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..40670d224f0ff896e7ac9de98c6ab81b4aa14df6 --- /dev/null +++ b/src/modules/atcoder/constants.ts @@ -0,0 +1,28 @@ +export function mapRating(rating: number): string { + if (rating >= 2800) return 'Red'; + if (rating >= 2400) return 'Orange'; + if (rating >= 2000) return 'Yellow'; + if (rating >= 1600) return 'Blue'; + if (rating >= 1200) return 'Cyan'; + if (rating >= 800) return 'Green'; + if (rating >= 400) return 'Brown'; + return 'Gray'; +} + +export const ATCODER_BASE_URL = 'https://atcoder.jp'; + +export const ATCODER_SELECTORS = { + AVATAR: '.avatar', + USERNAME: '.username', + KYU: 'h3 b', +} as const; + +export const ATCODER_LABELS = { + RATING: 'Rating', + MAX_RATING: 'Highest Rating', + RANK: 'Rank', + RATED_MATCHES: 'Rated Matches', + LAST_COMPETED: 'Last Competed', + COUNTRY: 'Country/Region', + BIRTH_YEAR: 'Birth Year', +} as const; diff --git a/src/modules/atcoder/handlers.ts b/src/modules/atcoder/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..e609ab9cebfa42cc75c5503b152c346896c76678 --- /dev/null +++ b/src/modules/atcoder/handlers.ts @@ -0,0 +1,48 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from './service'; +import type { UserQuery, ContestQuery } from './types'; + +export async function getUserRatingHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserRating(username); + return reply.send(data); +} + +export async function getUserHistoryHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserHistory(username); + return reply.send(data); +} + +export async function getContestStandingsHandler( + request: FastifyRequest<{ Querystring: ContestQuery & { extended?: boolean } }>, + reply: FastifyReply +) { + const { contestId, extended } = request.query; + const data = await service.getContestStandings(contestId, extended); + return reply.send(data); +} + +export async function getContestResultsHandler( + request: FastifyRequest<{ Querystring: ContestQuery }>, + reply: FastifyReply +) { + const { contestId } = request.query; + const data = await service.getContestResults(contestId); + return reply.send(data); +} + +export async function getVirtualStandingsHandler( + request: FastifyRequest<{ Querystring: ContestQuery & { showGhost?: boolean } }>, + reply: FastifyReply +) { + const { contestId, showGhost } = request.query; + const data = await service.getVirtualStandings(contestId, showGhost); + return reply.send(data); +} diff --git a/src/modules/atcoder/index.ts b/src/modules/atcoder/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..80d3f6afcc50cd09644707b9231d0138bbf31e4c --- /dev/null +++ b/src/modules/atcoder/index.ts @@ -0,0 +1,2 @@ +export { default as atcoderPlugin } from './routes'; +export * from './types'; diff --git a/src/modules/atcoder/provider.ts b/src/modules/atcoder/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..337d3b3cd109ded1704e80876260fb7ab9b1715b --- /dev/null +++ b/src/modules/atcoder/provider.ts @@ -0,0 +1,109 @@ +import { httpClient } from '../../shared/utils/http-client'; +import * as cheerio from 'cheerio'; +import { UserHistory, ContestStandings, AtCoderUserRating, ContestResult } from './types'; +import { ATCODER_BASE_URL, ATCODER_SELECTORS, ATCODER_LABELS } from './constants'; + +export async function fetchUserRating(username: string): Promise { + const url = `${ATCODER_BASE_URL}/users/${username}`; + try { + const { data } = await httpClient.get(url); + const $ = cheerio.load(data); + + // Check for 404 or "User not found" + if ($('title').text().includes('404') || $('body').text().includes('User not found')) { + throw new Error(`User '${username}' not found on AtCoder`); + } + + const extractText = (label: string) => { + const th = $(`th:contains('${label}')`); + if (th.length === 0) return null; + return th + .next('td') + .text() + .replace(/\s+/g, ' ') + .trim(); + }; + + const rawRating = extractText(ATCODER_LABELS.RATING); + if (rawRating === null) { + throw new Error('AtCoder schema change detected: Rating label not found'); + } + + const rating = parseInt(rawRating.split(' ')[0]) || 0; + const max_rating = parseInt(extractText(ATCODER_LABELS.MAX_RATING)?.split(' ')[0] || '0') || 0; + const rank = extractText(ATCODER_LABELS.RANK) || 'N/A'; + const contests_participated = parseInt(extractText(ATCODER_LABELS.RATED_MATCHES) || '0') || 0; + const last_competed = extractText(ATCODER_LABELS.LAST_COMPETED) || 'N/A'; + const country = extractText(ATCODER_LABELS.COUNTRY) || 'N/A'; + const birth_year = extractText(ATCODER_LABELS.BIRTH_YEAR) || 'N/A'; + + const avatarAttr = $(ATCODER_SELECTORS.AVATAR).attr('src'); + const avatar = avatarAttr + ? avatarAttr.startsWith('//') ? 'https:' + avatarAttr : avatarAttr + : ''; + const display_name = $(ATCODER_SELECTORS.USERNAME).first().text().trim(); + const kyu = $(ATCODER_SELECTORS.KYU).first().text().trim(); + + // Fetch rating history from the direct JSON endpoint + let rating_history: UserHistory[] = []; + try { + rating_history = await fetchUserHistory(username); + } catch (e) { + // Fallback to scraping if JSON endpoint fails + $('script').each((i, script) => { + const content = $(script).text(); + if (content.includes('var rating_history =')) { + const match = content.match(/var rating_history\s*=\s*(\[.*?\]);/); + if (match) { + try { + rating_history = JSON.parse(match[1]); + } catch (err) { } + } + } + }); + } + + return { + rating, + max_rating, + rank, + contests_participated, + last_competed, + country, + birth_year, + avatar, + display_name: display_name || username, + kyu, + rating_history, + }; + } catch (error: any) { + if (error.response?.status === 404) { + throw new Error(`User '${username}' not found on AtCoder`); + } + throw error; + } +} + +export async function fetchUserHistory(username: string): Promise { + const url = `https://atcoder.jp/users/${username}/history/json`; + const { data } = await httpClient.get(url); + return data; +} + +export async function fetchContestStandings(contestId: string, extended: boolean = false): Promise { + const url = `https://atcoder.jp/contests/${contestId}/standings/${extended ? 'extended/' : ''}json`; + const { data } = await httpClient.get(url); + return data; +} + +export async function fetchContestResults(contestId: string): Promise { + const url = `https://atcoder.jp/contests/${contestId}/results/json`; + const { data } = await httpClient.get(url); + return data; +} + +export async function fetchVirtualStandings(contestId: string, showGhost: boolean = true): Promise { + const url = `https://atcoder.jp/contests/${contestId}/standings/virtual/json?showGhost=${showGhost}`; + const { data } = await httpClient.get(url); + return data; +} diff --git a/src/modules/atcoder/routes.ts b/src/modules/atcoder/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c64f49b50a5166c455cd68691607a0249de4fc7 --- /dev/null +++ b/src/modules/atcoder/routes.ts @@ -0,0 +1,51 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from './handlers'; +import * as schemas from './schemas'; +import validateUsername from '../../shared/middlewares/validate'; +import type { UserQuery, ContestQuery } from './types'; + +const atcoderRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get<{ Querystring: UserQuery }>( + '/rating', + { + preHandler: [validateUsername], + schema: schemas.userRatingSchema, + }, + handlers.getUserRatingHandler + ); + + fastify.get<{ Querystring: UserQuery }>( + '/history', + { + preHandler: [validateUsername], + schema: schemas.userHistorySchema, + }, + handlers.getUserHistoryHandler + ); + + fastify.get<{ Querystring: ContestQuery & { extended?: boolean } }>( + '/standings', + { + schema: schemas.contestStandingsSchema, + }, + handlers.getContestStandingsHandler + ); + + fastify.get<{ Querystring: ContestQuery }>( + '/results', + { + schema: schemas.contestResultsSchema, + }, + handlers.getContestResultsHandler + ); + + fastify.get<{ Querystring: ContestQuery & { showGhost?: boolean } }>( + '/virtual-standings', + { + schema: schemas.virtualStandingsSchema, + }, + handlers.getVirtualStandingsHandler + ); +}; + +export default atcoderRoutes; diff --git a/src/modules/atcoder/schemas.ts b/src/modules/atcoder/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccc02b7fd831d76494d8e34dc4a60ecae51d3889 --- /dev/null +++ b/src/modules/atcoder/schemas.ts @@ -0,0 +1,143 @@ +export const userRatingSchema = { + summary: 'Get User Rating', + description: 'Fetches AtCoder user rating, rank, and platform details', + tags: ['AtCoder'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'AtCoder username' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + display_name: { type: 'string' }, + platform: { type: 'string' }, + rating: { type: 'number' }, + max_rating: { type: 'number' }, + level: { type: 'string' }, + rank: { type: 'string' }, + contests_participated: { type: 'number' }, + last_competed: { type: 'string' }, + kyu: { type: 'string' }, + country: { type: 'string' }, + birth_year: { type: 'string' }, + avatar: { type: 'string' }, + rating_history: { + type: 'array', + items: { + type: 'object', + properties: { + IsRated: { type: 'boolean' }, + Place: { type: 'number' }, + OldRating: { type: 'number' }, + NewRating: { type: 'number' }, + Performance: { type: 'number' }, + InnerPerformance: { type: 'number' }, + ContestScreenName: { type: 'string' }, + ContestName: { type: 'string' }, + ContestNameEn: { type: 'string' }, + EndTime: { type: 'string' }, + } + } + } + } + } + } +}; + +export const userHistorySchema = { + summary: 'Get User History', + description: 'Fetches AtCoder user rating history directly from the JSON endpoint', + tags: ['AtCoder'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'AtCoder username' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + IsRated: { type: 'boolean' }, + Place: { type: 'number' }, + OldRating: { type: 'number' }, + NewRating: { type: 'number' }, + Performance: { type: 'number' }, + InnerPerformance: { type: 'number' }, + ContestScreenName: { type: 'string' }, + ContestName: { type: 'string' }, + ContestNameEn: { type: 'string' }, + EndTime: { type: 'string' }, + } + } + } + } +}; + +export const contestStandingsSchema = { + summary: 'Get Contest Standings', + description: 'Fetches AtCoder contest standings in JSON format', + tags: ['AtCoder'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'string', description: 'AtCoder contest ID (e.g., abc300)' }, + extended: { type: 'boolean', description: 'Whether to fetch extended standings' }, + }, + required: ['contestId'], + }, + response: { + 200: { + type: 'object', + additionalProperties: true + } + } +}; + +export const contestResultsSchema = { + summary: 'Get Contest Results', + description: 'Fetches AtCoder contest results in JSON format', + tags: ['AtCoder'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'string', description: 'AtCoder contest ID' }, + }, + required: ['contestId'], + }, + response: { + 200: { + type: 'object', + additionalProperties: true + } + } +}; + +export const virtualStandingsSchema = { + summary: 'Get Virtual Standings', + description: 'Fetches AtCoder virtual standings in JSON format', + tags: ['AtCoder'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'string', description: 'AtCoder contest ID' }, + showGhost: { type: 'boolean', description: 'Whether to show ghost entries' }, + }, + required: ['contestId'], + }, + response: { + 200: { + type: 'object', + additionalProperties: true + } + } +}; + diff --git a/src/modules/atcoder/service.ts b/src/modules/atcoder/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..48ca5fc401ddd94f893dde41790a875aae59986f --- /dev/null +++ b/src/modules/atcoder/service.ts @@ -0,0 +1,63 @@ +import { mapRating } from './constants'; +import * as provider from './provider'; +import type { AtCoderRating, UserHistory, ContestStandings } from './types'; + +export async function getUserRating(username: string): Promise { + try { + const data = await provider.fetchUserRating(username); + return { + username, + display_name: data.display_name, + platform: 'atcoder', + rating: data.rating, + max_rating: data.max_rating, + level: mapRating(data.rating), + rank: data.rank, + contests_participated: data.contests_participated, + last_competed: data.last_competed, + kyu: data.kyu, + country: data.country, + birth_year: data.birth_year, + avatar: data.avatar, + }; + } catch (error: any) { + console.error(`AtCoder Error for ${username}:`, error.message); + throw new Error('Failed to fetch AtCoder user data'); + } +} + +export async function getUserHistory(username: string): Promise { + try { + return await provider.fetchUserHistory(username); + } catch (error: any) { + console.error(`AtCoder History Error for ${username}:`, error.message); + throw new Error('Failed to fetch AtCoder user history'); + } +} + +export async function getContestStandings(contestId: string, extended: boolean = false): Promise { + try { + return await provider.fetchContestStandings(contestId, extended); + } catch (error: any) { + console.error(`AtCoder Standings Error for ${contestId}:`, error.message); + throw new Error('Failed to fetch AtCoder contest standings'); + } +} + +export async function getContestResults(contestId: string): Promise { + try { + return await provider.fetchContestResults(contestId); + } catch (error: any) { + console.error(`AtCoder Results Error for ${contestId}:`, error.message); + throw new Error('Failed to fetch AtCoder contest results'); + } +} + +export async function getVirtualStandings(contestId: string, showGhost: boolean = true): Promise { + try { + return await provider.fetchVirtualStandings(contestId, showGhost); + } catch (error: any) { + console.error(`AtCoder Virtual Standings Error for ${contestId}:`, error.message); + throw new Error('Failed to fetch AtCoder virtual standings'); + } +} diff --git a/src/modules/atcoder/types.ts b/src/modules/atcoder/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..65cd668dd1bf8579aa2d9f6bf1942f6c7f4b2640 --- /dev/null +++ b/src/modules/atcoder/types.ts @@ -0,0 +1,95 @@ +export interface UserHistory { + IsRated: boolean; + Place: number; + OldRating: number; + NewRating: number; + Performance: number; + InnerPerformance: number; + ContestScreenName: string; + ContestName: string; + ContestNameEn: string; + EndTime: string; +} + +export interface AtCoderRating { + username: string; + display_name: string; + platform: string; + rating: number; + max_rating: number; + level: string; + rank: string; + contests_participated: number; + last_competed: string; + kyu: string; + country: string; + birth_year: string; + avatar: string; +} + +export interface UserQuery { + username: string; +} + +export interface ContestQuery { + contestId: string; +} + +export interface AtCoderUserRating { + rating: number; + max_rating: number; + rank: string; + contests_participated: number; + last_competed: string; + country: string; + birth_year: string; + avatar: string; + display_name: string; + kyu: string; + rating_history: UserHistory[]; +} + +export interface ContestResult { + IsRated: boolean; + Place: number; + OldRating: number; + NewRating: number; + Performance: number; + InnerPerformance: number; + ContestScreenName: string; + ContestName: string; + ContestNameEn: string; + EndTime: string; +} + +export interface StandingData { + Rank: number; + Additional: any; + UserScreenName: string; + UserDisplayName: string; + IsRated: boolean; + Rating: number; + OldRating: number; + TotalResult: { + Count: number; + Score: number; + Elapsed: number; + Penalty: number; + }; + TaskResults: { + [key: string]: { + Count: number; + Score: number; + Elapsed: number; + Status: number; + Pending: boolean; + }; + }; +} + +export interface ContestStandings { + Fixed: boolean; + AdditionalColumns: any[]; + TaskInfo: any[]; + StandingsData: StandingData[]; +} diff --git a/src/modules/codechef/constants.ts b/src/modules/codechef/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..24b6ef965cad7b63aac5c4c95e0f93c17e8a7310 --- /dev/null +++ b/src/modules/codechef/constants.ts @@ -0,0 +1,16 @@ +export function mapRating(rating: number): string { + if (rating >= 2500) return '7 star'; + if (rating >= 2200) return '6 star'; + if (rating >= 2000) return '5 star'; + if (rating >= 1800) return '4 star'; + if (rating >= 1600) return '3 star'; + if (rating >= 1400) return '2 star'; + return '1 star'; +} + +export const CODECHEF_BASE_URL = 'https://www.codechef.com/users/'; + +export const CODECHEF_SELECTORS = { + RATING: '.rating-number', + MAX_RATING: '.rating-header small', +} as const; diff --git a/src/modules/codechef/handlers.ts b/src/modules/codechef/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..13d83a200cdee43f646b9de0d5639def97df248c --- /dev/null +++ b/src/modules/codechef/handlers.ts @@ -0,0 +1,12 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from './service'; +import type { UserQuery } from './types'; + +export async function getUserRatingHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserRating(username); + return reply.send(data); +} diff --git a/src/modules/codechef/index.ts b/src/modules/codechef/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6db5ad87366054a6b873538a2ee50bba8471de8c --- /dev/null +++ b/src/modules/codechef/index.ts @@ -0,0 +1,2 @@ +export { default as codechefPlugin } from './routes'; +export * from './types'; diff --git a/src/modules/codechef/provider.ts b/src/modules/codechef/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f780e018d5e69bfb885f92062e82b527bbbe8b9 --- /dev/null +++ b/src/modules/codechef/provider.ts @@ -0,0 +1,40 @@ +import { httpClient } from '../../shared/utils/http-client'; +import * as cheerio from 'cheerio'; +import { CodeChefUserRating } from './types'; +import { CODECHEF_BASE_URL, CODECHEF_SELECTORS } from './constants'; + +export async function fetchUserRating(username: string): Promise { + const url = `${CODECHEF_BASE_URL}${username}`; + try { + const { data } = await httpClient.get(url); + const $ = cheerio.load(data); + + // Check if user exists on page (CodeChef usually shows a specific page for missing users or 404s) + if ($('body').text().includes('not found') || $('title').text().includes('404')) { + throw new Error(`User '${username}' not found on CodeChef`); + } + + const ratingElement = $(CODECHEF_SELECTORS.RATING).first(); + if (ratingElement.length === 0) { + throw new Error('CodeChef schema change detected: Rating selector not found'); + } + + const ratingText = ratingElement.text().trim(); + const rating = parseInt(ratingText); + + const maxRatingElement = $(CODECHEF_SELECTORS.MAX_RATING).first(); + const maxRatingText = maxRatingElement.text().match(/\d+/)?.[0]; + const max_rating = maxRatingText ? parseInt(maxRatingText) : undefined; + + if (isNaN(rating)) { + throw new Error('Could not parse CodeChef rating. Schema might have changed.'); + } + + return { rating, max_rating }; + } catch (error: any) { + if (error.response?.status === 404) { + throw new Error(`User '${username}' not found on CodeChef`); + } + throw error; + } +} diff --git a/src/modules/codechef/routes.ts b/src/modules/codechef/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..075bae6c266dc0b6ea5552a630c8516cf5f51e59 --- /dev/null +++ b/src/modules/codechef/routes.ts @@ -0,0 +1,18 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from './handlers'; +import * as schemas from './schemas'; +import validateUsername from '../../shared/middlewares/validate'; +import type { UserQuery } from './types'; + +const codechefRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get<{ Querystring: UserQuery }>( + '/rating', + { + preHandler: [validateUsername], + schema: schemas.userRatingSchema, + }, + handlers.getUserRatingHandler + ); +}; + +export default codechefRoutes; diff --git a/src/modules/codechef/schemas.ts b/src/modules/codechef/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..6abf22245187db610f3ef47795bc169f9928f008 --- /dev/null +++ b/src/modules/codechef/schemas.ts @@ -0,0 +1,23 @@ +export const userRatingSchema = { + summary: 'Get User Rating', + description: 'Fetches CodeChef user rating and platform details', + tags: ['CodeChef'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'CodeChef username' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + platform: { type: 'string' }, + rating: { type: 'number' } + } + } + } +}; + diff --git a/src/modules/codechef/service.ts b/src/modules/codechef/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3425d6b026cce70fd557f4471798c9eaf77c290 --- /dev/null +++ b/src/modules/codechef/service.ts @@ -0,0 +1,20 @@ +import { mapRating } from './constants'; +import * as provider from './provider'; +import type { CodeChefRating } from './types'; + +export async function getUserRating(username: string): Promise { + try { + const data = await provider.fetchUserRating(username); + + return { + username, + platform: 'codechef', + rating: data.rating, + level: mapRating(data.rating), + max_rating: data.max_rating, + }; + } catch (error: any) { + console.error(`CodeChef Error for ${username}:`, error.message); + throw new Error('Error fetching CodeChef rating'); + } +} diff --git a/src/modules/codechef/types.ts b/src/modules/codechef/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..812a53c2852a7d8ac8e44a5cd412ba33429250e5 --- /dev/null +++ b/src/modules/codechef/types.ts @@ -0,0 +1,15 @@ +export interface CodeChefRating { + username: string; + platform: string; + rating: number | string; + level: string; + max_rating?: number | string; +} + +export interface UserQuery { + username: string; +} +export interface CodeChefUserRating { + rating: number; + max_rating?: number; +} diff --git a/src/modules/codeforces/handlers.ts b/src/modules/codeforces/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9359ffa787832469d41589d13feb033d02368e2 --- /dev/null +++ b/src/modules/codeforces/handlers.ts @@ -0,0 +1,160 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from './service'; +import type { + UserQuery, + StatusQuery, + ProblemsetQuery, + RecentActionsQuery, + ContestQuery, + StandingsQuery, + ContestStatusQuery, + BlogEntryQuery, + RecentStatusQuery +} from './types'; + +export async function getUserRatingHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserRating(username); + return reply.send(data); +} + +export async function getContestHistoryHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getContestHistory(username); + return reply.send(data); +} + +export async function getUserStatusHandler( + request: FastifyRequest<{ Querystring: StatusQuery }>, + reply: FastifyReply +) { + const { username, from = 1, count = 10 } = request.query; + const data = await service.getUserStatus(username, Number(from), Number(count)); + return reply.send(data); +} + +export async function getUserBlogsHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserBlogs(username); + return reply.send(data); +} + +export async function getSolvedProblemsHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getSolvedProblems(username); + return reply.send(data); +} + +export async function getContestsHandler( + request: FastifyRequest<{ Querystring: { gym?: boolean } }>, + reply: FastifyReply +) { + const { gym = false } = request.query; + const data = await service.getContests(gym); + return reply.send(data); +} + +export async function getRecentActionsHandler( + request: FastifyRequest<{ Querystring: RecentActionsQuery }>, + reply: FastifyReply +) { + const { maxCount = 20 } = request.query; + const data = await service.getRecentActions(Number(maxCount)); + return reply.send(data); +} + +export async function getProblemsHandler( + request: FastifyRequest<{ Querystring: ProblemsetQuery }>, + reply: FastifyReply +) { + const { tags } = request.query; + const data = await service.getProblems(tags); + return reply.send(data); +} + +export async function getContestStandingsHandler( + request: FastifyRequest<{ Querystring: StandingsQuery }>, + reply: FastifyReply +) { + const { contestId, from, count, handles, room, showUnofficial } = request.query; + const data = await service.getContestStandings( + Number(contestId), + from ? Number(from) : undefined, + count ? Number(count) : undefined, + handles, + room ? Number(room) : undefined, + showUnofficial + ); + return reply.send(data); +} + +export async function getContestRatingChangesHandler( + request: FastifyRequest<{ Querystring: ContestQuery }>, + reply: FastifyReply +) { + const { contestId } = request.query; + const data = await service.getContestRatingChanges(Number(contestId)); + return reply.send(data); +} + +export async function getContestHacksHandler( + request: FastifyRequest<{ Querystring: ContestQuery }>, + reply: FastifyReply +) { + const { contestId } = request.query; + const data = await service.getContestHacks(Number(contestId)); + return reply.send(data); +} + +export async function getContestStatusHandler( + request: FastifyRequest<{ Querystring: ContestStatusQuery }>, + reply: FastifyReply +) { + const { contestId, handle, from, count } = request.query; + const data = await service.getContestStatus( + Number(contestId), + handle, + from ? Number(from) : undefined, + count ? Number(count) : undefined + ); + return reply.send(data); +} + +export async function getProblemsetRecentStatusHandler( + request: FastifyRequest<{ Querystring: RecentStatusQuery }>, + reply: FastifyReply +) { + const { count = 10 } = request.query; + const data = await service.getProblemsetRecentStatus(Number(count)); + return reply.send(data); +} + +export async function getBlogEntryHandler( + request: FastifyRequest<{ Querystring: BlogEntryQuery }>, + reply: FastifyReply +) { + const { blogEntryId } = request.query; + const data = await service.getBlogEntry(Number(blogEntryId)); + return reply.send(data); +} + +export async function getBlogCommentsHandler( + request: FastifyRequest<{ Querystring: BlogEntryQuery }>, + reply: FastifyReply +) { + const { blogEntryId } = request.query; + const data = await service.getBlogComments(Number(blogEntryId)); + return reply.send(data); +} diff --git a/src/modules/codeforces/index.ts b/src/modules/codeforces/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..df5a5a4597cc266672cbb82de0e0d6773de407b1 --- /dev/null +++ b/src/modules/codeforces/index.ts @@ -0,0 +1,2 @@ +export { default as codeforcesPlugin } from './routes'; +export * from './types'; diff --git a/src/modules/codeforces/provider.ts b/src/modules/codeforces/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..a09f9a5137a8f403f5f7b50766aee2d21dcabec5 --- /dev/null +++ b/src/modules/codeforces/provider.ts @@ -0,0 +1,221 @@ +import { httpClient } from '../../shared/utils/http-client'; +import type { + CodeforcesUser, + CodeforcesContestHistory, + CodeforcesSubmission, + BlogEntry, + CodeforcesContest, + RecentAction, + CodeforcesProblem, + ContestStandings, + RatingChange, + Hack, + BlogEntryView, + BlogComment, +} from './types'; + +const CODEFORCES_API_BASE = 'https://codeforces.com/api'; + +// Fetch user information +export async function fetchUserInfo(username: string): Promise { + const url = `${CODEFORCES_API_BASE}/user.info?handles=${username}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching user info'); + } + + return data.result[0]; +} + +// Fetch user's contest rating history +export async function fetchContestHistory( + username: string +): Promise { + const url = `${CODEFORCES_API_BASE}/user.rating?handle=${username}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching contest history'); + } + + return data.result; +} + +const MAX_CF_LIMIT = 1000; + +// Fetch user's submission status +export async function fetchUserStatus( + username: string, + from: number = 1, + count: number = 10 +): Promise { + const safeCount = Math.min(count, MAX_CF_LIMIT); + const url = `${CODEFORCES_API_BASE}/user.status?handle=${username}&from=${from}&count=${safeCount}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching user status'); + } + + return data.result; +} + +// Fetch user's blog entries +export async function fetchBlogEntries(username: string): Promise { + const url = `${CODEFORCES_API_BASE}/user.blogEntries?handle=${username}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching blog entries'); + } + + return data.result; +} + +// Fetch user submissions with limit +export async function fetchAllSubmissions( + username: string, + from: number = 1, + count: number = 100 +): Promise { + const safeCount = Math.min(count, MAX_CF_LIMIT); + const url = `${CODEFORCES_API_BASE}/user.status?handle=${username}&from=${from}&count=${safeCount}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching submissions'); + } + + return data.result; +} + +// Fetch contests +export async function fetchContests(gym: boolean = false): Promise { + const url = `${CODEFORCES_API_BASE}/contest.list?gym=${gym}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching contest list'); + } + + return data.result; +} + +// Fetch recent actions +export async function fetchRecentActions(maxCount: number = 20): Promise { + const url = `${CODEFORCES_API_BASE}/recentActions?maxCount=${maxCount}`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching recent actions'); + } + + return data.result; +} + +// Fetch problemset problems +export async function fetchProblems(tags?: string): Promise<{ problems: CodeforcesProblem[] }> { + const url = tags + ? `${CODEFORCES_API_BASE}/problemset.problems?tags=${tags}` + : `${CODEFORCES_API_BASE}/problemset.problems`; + const { data } = await httpClient.get(url); + + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching problemset'); + } + + return data.result; +} + +// Fetch contest standings +export async function fetchContestStandings( + contestId: number, + from?: number, + count?: number, + handles?: string, + room?: number, + showUnofficial?: boolean +): Promise { + let url = `${CODEFORCES_API_BASE}/contest.standings?contestId=${contestId}`; + if (from) url += `&from=${from}`; + if (count) url += `&count=${count}`; + if (handles) url += `&handles=${handles}`; + if (room) url += `&room=${room}`; + if (showUnofficial !== undefined) url += `&showUnofficial=${showUnofficial}`; + + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching contest standings'); + } + return data.result; +} + +// Fetch contest rating changes +export async function fetchContestRatingChanges(contestId: number): Promise { + const url = `${CODEFORCES_API_BASE}/contest.ratingChanges?contestId=${contestId}`; + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching rating changes'); + } + return data.result; +} + +// Fetch contest hacks +export async function fetchContestHacks(contestId: number): Promise { + const url = `${CODEFORCES_API_BASE}/contest.hacks?contestId=${contestId}`; + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching contest hacks'); + } + return data.result; +} + +// Fetch contest status (submissions) +export async function fetchContestStatus( + contestId: number, + handle?: string, + from?: number, + count?: number +): Promise { + let url = `${CODEFORCES_API_BASE}/contest.status?contestId=${contestId}`; + if (handle) url += `&handle=${handle}`; + if (from) url += `&from=${from}`; + if (count) url += `&count=${count}`; + + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching contest status'); + } + return data.result; +} + +// Fetch problemset recent status +export async function fetchProblemsetRecentStatus(count: number): Promise { + const url = `${CODEFORCES_API_BASE}/problemset.recentStatus?count=${count}`; + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching recent status'); + } + return data.result; +} + +// Fetch blog entry content +export async function fetchBlogEntry(blogEntryId: number): Promise { + const url = `${CODEFORCES_API_BASE}/blogEntry.view?blogEntryId=${blogEntryId}`; + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching blog entry'); + } + return data.result; +} + +// Fetch blog entry comments +export async function fetchBlogComments(blogEntryId: number): Promise { + const url = `${CODEFORCES_API_BASE}/blogEntry.comments?blogEntryId=${blogEntryId}`; + const { data } = await httpClient.get(url); + if (data.status !== 'OK') { + throw new Error(data.comment || 'Error fetching blog comments'); + } + return data.result; +} diff --git a/src/modules/codeforces/routes.ts b/src/modules/codeforces/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c23505d87ce4990573bdee382ac514be7a11819 --- /dev/null +++ b/src/modules/codeforces/routes.ts @@ -0,0 +1,98 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from './handlers'; +import * as schemas from './schemas'; +import validateUsername from '../../shared/middlewares/validate'; +import type { + StatusQuery, + UserQuery, + ContestQuery, + StandingsQuery, + ContestStatusQuery, + BlogEntryQuery, + RecentStatusQuery +} from './types'; + +const codeforcesRoutes: FastifyPluginAsync = async (fastify) => { + // User routes (with username validation) + fastify.get<{ Querystring: UserQuery }>( + '/rating', + { preHandler: [validateUsername], schema: schemas.userRatingSchema }, + handlers.getUserRatingHandler + ); + + fastify.get<{ Querystring: UserQuery }>( + '/contest-history', + { preHandler: [validateUsername], schema: schemas.contestHistorySchema }, + handlers.getContestHistoryHandler + ); + + fastify.get<{ Querystring: StatusQuery }>( + '/status', + { preHandler: [validateUsername], schema: schemas.userStatusSchema }, + handlers.getUserStatusHandler + ); + + fastify.get<{ Querystring: UserQuery }>( + '/blogs', + { preHandler: [validateUsername], schema: schemas.userBlogsSchema }, + handlers.getUserBlogsHandler + ); + + fastify.get<{ Querystring: UserQuery }>( + '/solved-problems', + { preHandler: [validateUsername], schema: schemas.solvedProblemsSchema }, + handlers.getSolvedProblemsHandler + ); + + // General platform routes + fastify.get('/contests', { schema: schemas.contestsSchema }, handlers.getContestsHandler); + fastify.get('/recent-actions', { schema: schemas.recentActionsSchema }, handlers.getRecentActionsHandler); + fastify.get('/problems', { schema: schemas.problemsSchema }, handlers.getProblemsHandler); + + // Contest specific routes + fastify.get<{ Querystring: StandingsQuery }>( + '/contest/standings', + { schema: schemas.contestStandingsSchema }, + handlers.getContestStandingsHandler + ); + + fastify.get<{ Querystring: ContestQuery }>( + '/contest/rating-changes', + { schema: schemas.contestRatingChangesSchema }, + handlers.getContestRatingChangesHandler + ); + + fastify.get<{ Querystring: ContestQuery }>( + '/contest/hacks', + { schema: schemas.contestHacksSchema }, + handlers.getContestHacksHandler + ); + + fastify.get<{ Querystring: ContestStatusQuery }>( + '/contest/status', + { schema: schemas.contestStatusSchema }, + handlers.getContestStatusHandler + ); + + // Problemset routes + fastify.get<{ Querystring: RecentStatusQuery }>( + '/problemset/recent-status', + { schema: schemas.problemsetRecentStatusSchema }, + handlers.getProblemsetRecentStatusHandler + ); + + // Blog routes + fastify.get<{ Querystring: BlogEntryQuery }>( + '/blog/view', + { schema: schemas.blogEntrySchema }, + handlers.getBlogEntryHandler + ); + + fastify.get<{ Querystring: BlogEntryQuery }>( + '/blog/comments', + { schema: schemas.blogCommentsSchema }, + handlers.getBlogCommentsHandler + ); +}; + +export default codeforcesRoutes; diff --git a/src/modules/codeforces/schemas.ts b/src/modules/codeforces/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dee602dc66873889c561a06f57396d2bee79052 --- /dev/null +++ b/src/modules/codeforces/schemas.ts @@ -0,0 +1,347 @@ +export const userRatingSchema = { + summary: 'Get User Rating', + description: 'Fetches Codeforces user rating and rank details', + tags: ['Codeforces - User'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Codeforces handle' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + platform: { type: 'string' }, + rating: { type: ['number', 'string'] }, + level: { type: 'string' }, + max_rating: { type: ['number', 'string'] }, + max_level: { type: 'string' }, + contribution: { type: 'number' }, + friendOfCount: { type: 'number' }, + avatar: { type: 'string' } + } + } + } +}; + +export const contestHistorySchema = { + summary: 'Get Contest History', + description: 'Fetches Codeforces contest participation history', + tags: ['Codeforces - User'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Codeforces handle' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + contestId: { type: 'number' }, + contestName: { type: 'string' }, + handle: { type: 'string' }, + rank: { type: 'number' }, + ratingUpdateTimeSeconds: { type: 'number' }, + oldRating: { type: 'number' }, + newRating: { type: 'number' } + } + } + } + } +}; + +export const userStatusSchema = { + summary: 'Get User Status', + description: 'Fetches Codeforces user submission status/history', + tags: ['Codeforces - User'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Codeforces handle' }, + from: { type: 'number', description: 'Starting index (1-based)', default: 1 }, + count: { type: 'number', description: 'Number of submissions to fetch', default: 10 }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + contestId: { type: 'number' }, + problem: { + type: 'object', + properties: { + contestId: { type: 'number' }, + index: { type: 'string' }, + name: { type: 'string' }, + rating: { type: 'number' }, + tags: { type: 'array', items: { type: 'string' } } + } + }, + verdict: { type: 'string' }, + programmingLanguage: { type: 'string' }, + creationTimeSeconds: { type: 'number' } + } + } + } + } +}; + +export const userBlogsSchema = { + summary: 'Get User Blogs', + description: 'Fetches blog posts written by a Codeforces user', + tags: ['Codeforces - User'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Codeforces handle' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' }, + creationTimeSeconds: { type: 'number' }, + rating: { type: 'number' } + } + } + } + } +}; + +export const solvedProblemsSchema = { + summary: 'Get Solved Problems', + description: 'Fetches a list of problems solved by a Codeforces user', + tags: ['Codeforces - User'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Codeforces handle' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + rating: { type: 'number' }, + tags: { type: 'array', items: { type: 'string' } }, + link: { type: 'string' } + } + } + } + } +}; + +export const contestsSchema = { + summary: 'Get Contests', + description: 'Fetches a list of Codeforces contests', + tags: ['Codeforces - Contests'], + querystring: { + type: 'object', + properties: { + gym: { type: 'boolean', description: 'Whether to include gym contests', default: false } + } + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + type: { type: 'string' }, + phase: { type: 'string' }, + frozen: { type: 'boolean' }, + durationSeconds: { type: 'number' }, + startTimeSeconds: { type: 'number' }, + relativeTimeSeconds: { type: 'number' } + } + } + } + } +}; + +export const recentActionsSchema = { + summary: 'Get Recent Actions', + description: 'Fetches recent actions on Codeforces (blogs, comments, etc.)', + tags: ['Codeforces - Blog'], + querystring: { + type: 'object', + properties: { + maxCount: { type: 'number', description: 'Maximum number of actions to fetch', default: 20 } + } + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + timeSeconds: { type: 'number' }, + blogEntry: { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' } + } + }, + comment: { + type: 'object', + properties: { + id: { type: 'number' }, + text: { type: 'string' }, + commentatorHandle: { type: 'string' } + } + } + } + } + } + } +}; + +export const problemsSchema = { + summary: 'Get Problemset Problems', + description: 'Fetches problems from the Codeforces problemset', + tags: ['Codeforces - Problems'], + querystring: { + type: 'object', + properties: { + tags: { type: 'string', description: 'Semicolon-separated list of tags' } + } + }, + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + contestId: { type: 'number' }, + index: { type: 'string' }, + name: { type: 'string' }, + rating: { type: 'number' }, + tags: { type: 'array', items: { type: 'string' } } + } + } + } + } +}; + +export const contestStandingsSchema = { + summary: 'Get Contest Standings', + description: 'Fetches the scoreboard of a specific contest', + tags: ['Codeforces - Contests'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'number', description: 'ID of the contest' }, + from: { type: 'number', description: 'Starting index (1-based)', default: 1 }, + count: { type: 'number', description: 'Number of rows to fetch', default: 10 }, + handles: { type: 'string', description: 'Semicolon-separated list of handles' }, + room: { type: 'number', description: 'Room number' }, + showUnofficial: { type: 'boolean', description: 'Whether to show unofficial results', default: false } + }, + required: ['contestId'] + } +}; + +export const contestRatingChangesSchema = { + summary: 'Get Contest Rating Changes', + description: 'Fetches rating changes for all participants after a contest', + tags: ['Codeforces - Contests'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'number', description: 'ID of the contest' } + }, + required: ['contestId'] + } +}; + +export const contestHacksSchema = { + summary: 'Get Contest Hacks', + description: 'Fetches a list of all hacks in a contest', + tags: ['Codeforces - Contests'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'number', description: 'ID of the contest' } + }, + required: ['contestId'] + } +}; + +export const contestStatusSchema = { + summary: 'Get Contest Status', + description: 'Fetches submissions for a specific contest', + tags: ['Codeforces - Contests'], + querystring: { + type: 'object', + properties: { + contestId: { type: 'number', description: 'ID of the contest' }, + handle: { type: 'string', description: 'Codeforces handle' }, + from: { type: 'number', description: 'Starting index (1-based)', default: 1 }, + count: { type: 'number', description: 'Number of submissions to fetch', default: 10 } + }, + required: ['contestId'] + } +}; + +export const problemsetRecentStatusSchema = { + summary: 'Get Problemset Recent Status', + description: 'Fetches recent submissions across the platform', + tags: ['Codeforces - Problems'], + querystring: { + type: 'object', + properties: { + count: { type: 'number', description: 'Number of submissions to fetch', default: 10 } + }, + required: ['count'] + } +}; + +export const blogEntrySchema = { + summary: 'Get Blog Entry', + description: 'Fetches a specific blog entry', + tags: ['Codeforces - Blog'], + querystring: { + type: 'object', + properties: { + blogEntryId: { type: 'number', description: 'ID of the blog entry' } + }, + required: ['blogEntryId'] + } +}; + +export const blogCommentsSchema = { + summary: 'Get Blog Comments', + description: 'Fetches comments for a specific blog entry', + tags: ['Codeforces - Blog'], + querystring: { + type: 'object', + properties: { + blogEntryId: { type: 'number', description: 'ID of the blog entry' } + }, + required: ['blogEntryId'] + } +}; diff --git a/src/modules/codeforces/service.ts b/src/modules/codeforces/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d616fb4160e66d2c00eb41c2cb616eebd1f41fb --- /dev/null +++ b/src/modules/codeforces/service.ts @@ -0,0 +1,217 @@ +import * as provider from './provider'; +import type { + CodeforcesRatingResponse, + CodeforcesContestHistory, + CodeforcesSubmission, + SolvedProblem, + BlogEntry, + CodeforcesContest, + RecentAction, + CodeforcesProblem, + ContestStandings, + RatingChange, + Hack, +} from './types'; + +// Get user's rating +export async function getUserRating(username: string): Promise { + try { + const user = await provider.fetchUserInfo(username); + + return { + username, + platform: 'codeforces', + rating: user.rating || 'Unrated', + level: user.rank || 'Unrated', + max_rating: user.maxRating || 'Unrated', + max_level: user.maxRank || 'Unrated', + contribution: user.contribution, + friendOfCount: user.friendOfCount, + avatar: user.avatar, + }; + } catch (error: any) { + console.error(`Codeforces Error for ${username}:`, error.message); + throw new Error('Error fetching Codeforces rating'); + } +} + +// Get user's contest history +export async function getContestHistory( + username: string +): Promise { + try { + return await provider.fetchContestHistory(username); + } catch (error: any) { + console.error(`Codeforces Error for ${username}:`, error.message); + throw new Error('Error fetching Codeforces contest history'); + } +} + +// Get user's submission status +export async function getUserStatus( + username: string, + from: number = 1, + count: number = 10 +): Promise { + try { + return await provider.fetchUserStatus(username, from, count); + } catch (error: any) { + console.error(`Codeforces Error for ${username}:`, error.message); + throw new Error('Error fetching Codeforces user status'); + } +} + +// Get user's blog entries +export async function getUserBlogs(username: string): Promise { + try { + return await provider.fetchBlogEntries(username); + } catch (error: any) { + console.error(`Codeforces Error for ${username}:`, error.message); + throw new Error('Error fetching Codeforces user blog entries'); + } +} + +// Get user's solved problems +export async function getSolvedProblems(username: string): Promise { + try { + const submissions = await provider.fetchAllSubmissions(username, 1, 1000); + + const solvedProblemsSet = new Set(); + const solvedProblems: SolvedProblem[] = []; + + submissions.forEach((submission) => { + if (submission.verdict === 'OK') { + const problemId = `${submission.problem.contestId}${submission.problem.index}`; + + if (!solvedProblemsSet.has(problemId)) { + solvedProblemsSet.add(problemId); + solvedProblems.push({ + id: problemId, + name: submission.problem.name, + rating: submission.problem.rating, + tags: submission.problem.tags, + link: `https://codeforces.com/contest/${submission.problem.contestId}/problem/${submission.problem.index}`, + }); + } + } + }); + + return solvedProblems; + } catch (error: any) { + console.error(`Codeforces Error for ${username}:`, error.message); + throw new Error('Error fetching Codeforces solved problems'); + } +} + +// Get contests +export async function getContests(gym: boolean = false): Promise { + try { + return await provider.fetchContests(gym); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces contests'); + } +} + +// Get recent actions +export async function getRecentActions(maxCount: number = 20): Promise { + try { + return await provider.fetchRecentActions(maxCount); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces recent actions'); + } +} + +// Get problemset problems +export async function getProblems(tags?: string): Promise { + try { + const result = await provider.fetchProblems(tags); + return result.problems; + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces problemset'); + } +} + +// Get contest standings +export async function getContestStandings( + contestId: number, + from?: number, + count?: number, + handles?: string, + room?: number, + showUnofficial?: boolean +): Promise { + try { + return await provider.fetchContestStandings(contestId, from, count, handles, room, showUnofficial); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces contest standings'); + } +} + +// Get rating changes +export async function getContestRatingChanges(contestId: number): Promise { + try { + return await provider.fetchContestRatingChanges(contestId); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces contest rating changes'); + } +} + +// Get contest hacks +export async function getContestHacks(contestId: number): Promise { + try { + return await provider.fetchContestHacks(contestId); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces contest hacks'); + } +} + +// Get contest status +export async function getContestStatus( + contestId: number, + handle?: string, + from?: number, + count?: number +): Promise { + try { + return await provider.fetchContestStatus(contestId, handle, from, count); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces contest status'); + } +} + +// Get problemset recent status +export async function getProblemsetRecentStatus(count: number): Promise { + try { + return await provider.fetchProblemsetRecentStatus(count); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces problemset recent status'); + } +} + +// Get blog entry +export async function getBlogEntry(blogEntryId: number): Promise { + try { + return await provider.fetchBlogEntry(blogEntryId); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces blog entry'); + } +} + +// Get blog comments +export async function getBlogComments(blogEntryId: number): Promise { + try { + return await provider.fetchBlogComments(blogEntryId); + } catch (error: any) { + console.error('Codeforces Error:', error.message); + throw new Error('Error fetching Codeforces blog comments'); + } +} diff --git a/src/modules/codeforces/types.ts b/src/modules/codeforces/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..30b42f37bedaa267c7659948fbdcb7fd91ccdbf0 --- /dev/null +++ b/src/modules/codeforces/types.ts @@ -0,0 +1,189 @@ +// Codeforces-specific TypeScript types + +export interface CodeforcesUser { + handle: string; + rating?: number; + rank?: string; + maxRating?: number; + maxRank?: string; + contribution?: number; + friendOfCount?: number; + avatar?: string; + titlePhoto?: string; + lastOnlineTimeSeconds?: number; + registrationTimeSeconds?: number; +} + +export interface CodeforcesRatingResponse { + username: string; + platform: string; + rating: number | string; + level: string; + max_rating?: number | string; + max_level?: string; + contribution?: number; + friendOfCount?: number; + avatar?: string; +} + +export interface CodeforcesContestHistory { + contestId: number; + contestName: string; + handle: string; + rank: number; + ratingUpdateTimeSeconds: number; + oldRating: number; + newRating: number; +} + +export interface CodeforcesProblem { + contestId: number; + index: string; + name: string; + rating?: number; + tags: string[]; +} + +export interface CodeforcesSubmission { + id: number; + contestId?: number; + problem: CodeforcesProblem; + verdict: string; + programmingLanguage: string; + creationTimeSeconds: number; +} + +export interface SolvedProblem { + id: string; + name: string; + rating?: number; + tags: string[]; + link: string; +} + +export interface BlogEntry { + id: number; + title: string; + creationTimeSeconds: number; + rating: number; +} + +export interface UserQuery { + username: string; +} + +export interface StatusQuery { + username: string; + from?: number; + count?: number; +} + +export interface CodeforcesContest { + id: number; + name: string; + type: string; + phase: string; + frozen: boolean; + durationSeconds: number; + startTimeSeconds?: number; + relativeTimeSeconds?: number; +} + +export interface RecentAction { + timeSeconds: number; + blogEntry?: BlogEntry; + comment?: { + id: number; + creationTimeSeconds: number; + commentatorHandle: string; + text: string; + }; +} + +export interface RankingRow { + party: any; + rank: number; + points: number; + penalty: number; + successfulHackCount: number; + unsuccessfulHackCount: number; + problemResults: any[]; +} + +export interface ContestStandings { + contest: CodeforcesContest; + problems: CodeforcesProblem[]; + rows: RankingRow[]; +} + +export interface RatingChange { + contestId: number; + contestName: string; + handle: string; + rank: number; + ratingUpdateTimeSeconds: number; + oldRating: number; + newRating: number; +} + +export interface Hack { + id: number; + creationTimeSeconds: number; + hacker: any; + defender: any; + verdict?: string; + problem: CodeforcesProblem; +} + +export interface ProblemsetQuery { + tags?: string; +} + +export interface RecentActionsQuery { + maxCount: number; +} + +export interface ContestQuery { + contestId: number; +} + +export interface StandingsQuery { + contestId: number; + from?: number; + count?: number; + handles?: string; + room?: number; + showUnofficial?: boolean; +} + +export interface ContestStatusQuery { + contestId: number; + handle?: string; + from?: number; + count?: number; +} + +export interface BlogEntryQuery { + blogEntryId: number; +} + +export interface RecentStatusQuery { + count: number; +} + +export interface BlogEntryView { + id: number; + title: string; + content: string; + creationTimeSeconds: number; + rating: number; + authorHandle: string; +} + +export interface BlogComment { + id: number; + creationTimeSeconds: number; + commentatorHandle: string; + text: string; + rating: number; +} diff --git a/src/modules/gfg/constants.ts b/src/modules/gfg/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..a51a6d28e39df1858a439889b20c28e72c855840 --- /dev/null +++ b/src/modules/gfg/constants.ts @@ -0,0 +1,15 @@ +export const GFG_BASE_URL = 'https://www.geeksforgeeks.org/user/'; + +export const GFG_SELECTORS = { + NEXT_DATA: 'script#__NEXT_DATA__', +} as const; + +export function mapRating(rating: number): string { + if (rating >= 2500) return '7 star'; + if (rating >= 2200) return '6 star'; + if (rating >= 2000) return '5 star'; + if (rating >= 1800) return '4 star'; + if (rating >= 1600) return '3 star'; + if (rating >= 1400) return '2 star'; + return '1 star'; +} diff --git a/src/modules/gfg/handlers.ts b/src/modules/gfg/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfc6cae6ad5aa3ca94693652df2683e05674820c --- /dev/null +++ b/src/modules/gfg/handlers.ts @@ -0,0 +1,52 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from './service'; +import type { + UserQuery, + SubmissionsQuery, + UserPostsQuery, + LeaderboardQuery, + PromotionalEventsQuery +} from './types'; + +export async function getUserRatingHandler( + request: FastifyRequest<{ Querystring: UserQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserRating(username); + return reply.send(data); +} + +export async function getUserSubmissionsHandler( + request: FastifyRequest<{ Body: SubmissionsQuery }>, + reply: FastifyReply +) { + const data = await service.getUserSubmissions(request.body); + return reply.send(data); +} + +export async function getUserPostsHandler( + request: FastifyRequest<{ Params: { username: string }, Querystring: Omit }>, + reply: FastifyReply +) { + const { username } = request.params; + const { fetch_type, page } = request.query; + const data = await service.getUserPosts({ username, fetch_type, page }); + return reply.send(data); +} + +export async function getPromotionalEventsHandler( + request: FastifyRequest<{ Querystring: PromotionalEventsQuery }>, + reply: FastifyReply +) { + const data = await service.getPromotionalEvents(request.query); + return reply.send(data); +} + +export async function getContestLeaderboardHandler( + request: FastifyRequest<{ Querystring: LeaderboardQuery }>, + reply: FastifyReply +) { + const data = await service.getContestLeaderboard(request.query); + return reply.send(data); +} diff --git a/src/modules/gfg/index.ts b/src/modules/gfg/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c426060b0b050c710454b75055fff7135def26c4 --- /dev/null +++ b/src/modules/gfg/index.ts @@ -0,0 +1,2 @@ +export { default as gfgPlugin } from './routes'; +export * from './types'; diff --git a/src/modules/gfg/provider.ts b/src/modules/gfg/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcc3fb594df408ac259104297dd4ab87bcca1578 --- /dev/null +++ b/src/modules/gfg/provider.ts @@ -0,0 +1,95 @@ +import { httpClient } from '../../shared/utils/http-client'; +import * as cheerio from 'cheerio'; +import type { + GFGSubmissionsResponse, + SubmissionsQuery, + UserPostsQuery, + LeaderboardQuery, + PromotionalEventsQuery, + GFGUserRating, + GFGPost, + GFGPromotionalEvent, + GFGLeaderboard, +} from './types'; +import { GFG_BASE_URL, GFG_SELECTORS } from './constants'; + +export async function fetchUserRating(username: string): Promise { + const url = `${GFG_BASE_URL}${username}/`; + try { + const { data } = await httpClient.get(url); + const $ = cheerio.load(data); + + // Check for 404 or "User not found" + if ($('title').text().includes('404') || $('body').text().includes('User not found')) { + throw new Error(`User '${username}' not found on GeeksforGeeks`); + } + + const scriptContent = $(GFG_SELECTORS.NEXT_DATA).html(); + if (!scriptContent) { + throw new Error('GeeksforGeeks schema change detected: NEXT_DATA script not found'); + } + + const jsonData = JSON.parse(scriptContent); + const userData = jsonData?.props?.pageProps?.contestData?.user_contest_data; + const stars = jsonData?.props?.pageProps?.contestData?.user_stars; + + if (!userData && !jsonData?.props?.pageProps?.contestData) { + throw new Error(`User '${username}' contest data not found. Profile might be private or schema changed.`); + } + + return { + rating: userData?.current_rating || 'Unrated', + stars: stars ? `${stars} star` : 'Unrated', + }; + } catch (error: any) { + if (error.response?.status === 404) { + throw new Error(`User '${username}' not found on GeeksforGeeks`); + } + throw error; + } +} + +export async function fetchUserSubmissions(query: SubmissionsQuery): Promise { + const url = 'https://practiceapi.geeksforgeeks.org/api/v1/user/problems/submissions/'; + const { data } = await httpClient.post(url, { + handle: query.handle, + requestType: query.requestType || "", + year: query.year || "", + month: query.month || "" + }); + return data; +} + +export async function fetchUserPosts(query: UserPostsQuery): Promise { + const url = `https://communityapi.geeksforgeeks.org/post/user/${query.username}/`; + const { data } = await httpClient.get(url, { + params: { + fetch_type: query.fetch_type || 'posts', + page: query.page || 1 + } + }); + return data; +} + +export async function fetchPromotionalEvents(query: PromotionalEventsQuery): Promise { + const url = 'https://practiceapi.geeksforgeeks.org/api/vr/events/promotional/'; + const { data } = await httpClient.get(url, { + params: { + page_source: query.page_source, + user_country_code: query.user_country_code || 'IN' + } + }); + return data; +} + +export async function fetchContestLeaderboard(query: LeaderboardQuery): Promise { + const url = 'https://practiceapi.geeksforgeeks.org/api/latest/events/recurring/gfg-weekly-coding-contest/leaderboard/'; + const { data } = await httpClient.get(url, { + params: { + leaderboard_type: query.leaderboard_type || 0, + page: query.page || 1, + year_month: query.year_month || "" + } + }); + return data; +} diff --git a/src/modules/gfg/routes.ts b/src/modules/gfg/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..a35fa25e4b7de706d8794b4238c66e89bb3db4db --- /dev/null +++ b/src/modules/gfg/routes.ts @@ -0,0 +1,58 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from './handlers'; +import * as schemas from './schemas'; +import validateUsername from '../../shared/middlewares/validate'; +import type { + UserQuery, + SubmissionsQuery, + UserPostsQuery, + LeaderboardQuery, + PromotionalEventsQuery +} from './types'; + +const gfgRoutes: FastifyPluginAsync = async (fastify) => { + // Legacy mapping (GET for consistency with other platforms) + fastify.get<{ Querystring: UserQuery }>( + '/rating', + { + preHandler: [validateUsername], + schema: schemas.userRatingSchema, + }, + handlers.getUserRatingHandler + ); + + // New APIs + fastify.post<{ Body: SubmissionsQuery }>( + '/submissions', + { + schema: schemas.userSubmissionsSchema, + }, + handlers.getUserSubmissionsHandler + ); + + fastify.get<{ Params: { username: string }, Querystring: Omit }>( + '/posts/:username', + { + schema: schemas.userPostsSchema, + }, + handlers.getUserPostsHandler + ); + + fastify.get<{ Querystring: PromotionalEventsQuery }>( + '/events/promotional', + { + schema: schemas.promotionalEventsSchema, + }, + handlers.getPromotionalEventsHandler + ); + + fastify.get<{ Querystring: LeaderboardQuery }>( + '/leaderboard', + { + schema: schemas.contestLeaderboardSchema, + }, + handlers.getContestLeaderboardHandler + ); +}; + +export default gfgRoutes; diff --git a/src/modules/gfg/schemas.ts b/src/modules/gfg/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..711e42c52fd3094624333bde9c764316e0568d6d --- /dev/null +++ b/src/modules/gfg/schemas.ts @@ -0,0 +1,87 @@ +export const userRatingSchema = { + summary: 'Get User Rating', + description: 'Fetches GeeksforGeeks user rating and platform details', + tags: ['GFG'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'GFG username' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + platform: { type: 'string' }, + rating: { type: ['number', 'string'] }, + level: { type: 'string' } + } + } + } +}; + +export const userSubmissionsSchema = { + summary: 'Get User Submissions', + description: 'Fetches problems solved by a GFG user', + tags: ['GFG'], + body: { + type: 'object', + properties: { + handle: { type: 'string', description: 'GFG handle' }, + requestType: { type: 'string', default: "" }, + year: { type: 'string', default: "" }, + month: { type: 'string', default: "" } + }, + required: ['handle'] + } +}; + +export const userPostsSchema = { + summary: 'Get User Posts', + description: 'Fetches articles and posts written by a GFG user', + tags: ['GFG'], + params: { + type: 'object', + properties: { + username: { type: 'string' } + }, + required: ['username'] + }, + querystring: { + type: 'object', + properties: { + fetch_type: { type: 'string', default: 'posts' }, + page: { type: 'number', default: 1 } + } + } +}; + +export const promotionalEventsSchema = { + summary: 'Get Promotional Events', + description: 'Fetches promotional events from GFG', + tags: ['GFG'], + querystring: { + type: 'object', + properties: { + page_source: { type: 'string' }, + user_country_code: { type: 'string', default: 'IN' } + }, + required: ['page_source'] + } +}; + +export const contestLeaderboardSchema = { + summary: 'Get Contest Leaderboard', + description: 'Fetches the leaderboard for GFG weekly coding contests', + tags: ['GFG'], + querystring: { + type: 'object', + properties: { + leaderboard_type: { type: 'number', default: 0 }, + page: { type: 'number', default: 1 }, + year_month: { type: 'string', default: "" } + } + } +}; diff --git a/src/modules/gfg/service.ts b/src/modules/gfg/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8d8531165a9495bfd0579d62f1d2509a607be70 --- /dev/null +++ b/src/modules/gfg/service.ts @@ -0,0 +1,61 @@ +import * as provider from './provider'; +import type { + GFGRating, + SubmissionsQuery, + GFGSubmissionsResponse, + UserPostsQuery, + LeaderboardQuery, + PromotionalEventsQuery +} from './types'; + +export async function getUserRating(username: string): Promise { + try { + const data = await provider.fetchUserRating(username); + + return { + username, + platform: 'gfg', + rating: data.rating, + level: data.stars, + }; + } catch (error: any) { + console.error(`GFG Error for ${username}:`, error.message); + throw new Error('Error fetching GFG user data'); + } +} + +export async function getUserSubmissions(query: SubmissionsQuery): Promise { + try { + return await provider.fetchUserSubmissions(query); + } catch (error: any) { + console.error(`GFG Submissions Error for ${query.handle}:`, error.message); + throw new Error('Error fetching GFG submissions'); + } +} + +export async function getUserPosts(query: UserPostsQuery): Promise { + try { + return await provider.fetchUserPosts(query); + } catch (error: any) { + console.error(`GFG Posts Error for ${query.username}:`, error.message); + throw new Error('Error fetching GFG user posts'); + } +} + +export async function getPromotionalEvents(query: PromotionalEventsQuery): Promise { + try { + return await provider.fetchPromotionalEvents(query); + } catch (error: any) { + console.error('GFG Promotional Events Error:', error.message); + throw new Error('Error fetching GFG promotional events'); + } +} + +export async function getContestLeaderboard(query: LeaderboardQuery): Promise { + try { + return await provider.fetchContestLeaderboard(query); + } catch (error: any) { + console.error('GFG Leaderboard Error:', error.message); + throw new Error('Error fetching GFG contest leaderboard'); + } +} diff --git a/src/modules/gfg/types.ts b/src/modules/gfg/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa164b77f60651449116bbf204406b4d4e923b18 --- /dev/null +++ b/src/modules/gfg/types.ts @@ -0,0 +1,78 @@ +export interface GFGRating { + username: string; + platform: string; + rating: number | string; + level: string; +} + +export interface UserQuery { + username: string; +} + +export interface SubmissionsQuery { + handle: string; + requestType?: string; + year?: string; + month?: string; +} + +export interface GFGSubmission { + slug: string; + pname: string; + lang: string; +} + +export interface GFGSubmissionsResponse { + status: string; + message: string; + result: { + [difficulty: string]: { + [id: string]: GFGSubmission; + }; + }; + count: number; +} + +export interface UserPostsQuery { + username: string; + fetch_type?: string; + page?: number; +} + +export interface LeaderboardQuery { + leaderboard_type?: number; + page?: number; + year_month?: string; +} + +export interface PromotionalEventsQuery { + page_source: string; + user_country_code?: string; +} +export interface GFGUserRating { + rating: number | string; + stars: string; +} + +export interface GFGPost { + id: number; + title: string; + content: string; + creationDate: string; + author: string; +} + +export interface GFGPromotionalEvent { + id: number; + link: string; + title: string; + image: string; +} + +export interface GFGLeaderboard { + users: { + handle: string; + rank: number; + score: number; + }[]; +} diff --git a/src/modules/leetcode/constants.ts b/src/modules/leetcode/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..27186d403ee31bb6bca4cd0377c2483c7dc40be4 --- /dev/null +++ b/src/modules/leetcode/constants.ts @@ -0,0 +1,18 @@ +export const LEETCODE_API_URL = 'https://leetcode.com/graphql'; + +export const LEETCODE_HEADERS = { + 'authority': 'leetcode.com', + 'accept': '*/*', + 'accept-language': 'en-US,en;q=0.9', + 'content-type': 'application/json', + 'origin': 'https://leetcode.com', + 'referer': 'https://leetcode.com/', + 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', +}; diff --git a/src/modules/leetcode/handlers/contest.ts b/src/modules/leetcode/handlers/contest.ts new file mode 100644 index 0000000000000000000000000000000000000000..534be799913d39c0733bec9b4c5a4274fa8e23d7 --- /dev/null +++ b/src/modules/leetcode/handlers/contest.ts @@ -0,0 +1,36 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from '../services'; +import type { ContestRankingQuery } from '../types'; + +export async function getContestRankingHandler( + request: FastifyRequest<{ Querystring: ContestRankingQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getContestRankingInfo(username); + return reply.send(data); +} + +export async function getContestHistogramHandler( + request: FastifyRequest, + reply: FastifyReply +) { + const data = await service.getContestHistogram(); + return reply.send(data); +} + +export async function getAllContestsHandler( + request: FastifyRequest, + reply: FastifyReply +) { + const data = await service.getAllContests(); + return reply.send(data); +} + +export async function getUpcomingContestsHandler( + request: FastifyRequest, + reply: FastifyReply +) { + const data = await service.getUpcomingContests(); + return reply.send(data); +} diff --git a/src/modules/leetcode/handlers/discussion.ts b/src/modules/leetcode/handlers/discussion.ts new file mode 100644 index 0000000000000000000000000000000000000000..df2fcfd9c8a1a8861edcd8a9ae74525fd9ea203e --- /dev/null +++ b/src/modules/leetcode/handlers/discussion.ts @@ -0,0 +1,30 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from '../services'; + +export async function getTrendingDiscussHandler( + request: FastifyRequest<{ Querystring: { first?: string } }>, + reply: FastifyReply +) { + const first = parseInt(request.query.first || '20', 10); + const data = await service.getTrendingDiscuss(first); + return reply.send(data); +} + +export async function getDiscussTopicHandler( + request: FastifyRequest<{ Params: { topicId: string } }>, + reply: FastifyReply +) { + const topicId = parseInt(request.params.topicId, 10); + const data = await service.getDiscussTopic(topicId); + return reply.send(data); +} + +export async function getDiscussCommentsHandler( + request: FastifyRequest<{ Params: { topicId: string }; Querystring: any }>, + reply: FastifyReply +) { + const topicId = parseInt(request.params.topicId, 10); + const query = (request.query || {}) as Record; + const data = await service.getDiscussComments({ topicId, ...query }); + return reply.send(data); +} diff --git a/src/modules/leetcode/handlers/index.ts b/src/modules/leetcode/handlers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..71082fda63e06e4aa7dd8129650c555007975171 --- /dev/null +++ b/src/modules/leetcode/handlers/index.ts @@ -0,0 +1,4 @@ +export * from './contest'; +export * from './user'; +export * from './problem'; +export * from './discussion'; diff --git a/src/modules/leetcode/handlers/problem.ts b/src/modules/leetcode/handlers/problem.ts new file mode 100644 index 0000000000000000000000000000000000000000..8215487af35b0e188b959f5d962c9dce1fc5a885 --- /dev/null +++ b/src/modules/leetcode/handlers/problem.ts @@ -0,0 +1,37 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from '../services'; + +export async function getDailyProblemHandler(request: FastifyRequest, reply: FastifyReply) { + const raw = (request.query as any).raw === 'true'; + const data = await service.getDailyProblem(raw); + return reply.send(data); +} + +export async function getSelectProblemHandler( + request: FastifyRequest<{ Querystring: { titleSlug: string; raw?: string } }>, + reply: FastifyReply +) { + const { titleSlug, raw } = request.query; + if (!titleSlug) { + return reply.status(400).send({ error: 'Missing titleSlug query parameter' }); + } + const data = await service.getSelectProblem(titleSlug, raw === 'true'); + return reply.send(data); +} + +export async function getProblemsHandler(request: FastifyRequest, reply: FastifyReply) { + const data = await service.getProblems(request.query); + return reply.send(data); +} + +export async function getOfficialSolutionHandler( + request: FastifyRequest<{ Querystring: { titleSlug: string } }>, + reply: FastifyReply +) { + const { titleSlug } = request.query; + if (!titleSlug) { + return reply.status(400).send({ error: 'Missing titleSlug query parameter' }); + } + const data = await service.getOfficialSolution(titleSlug); + return reply.send(data); +} diff --git a/src/modules/leetcode/handlers/user.ts b/src/modules/leetcode/handlers/user.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e5b2505803474dd5498c15351a75d483afe00f5 --- /dev/null +++ b/src/modules/leetcode/handlers/user.ts @@ -0,0 +1,123 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from '../services'; +import type { ContestRankingQuery } from '../types'; + +export async function getUserRatingHandler( + request: FastifyRequest<{ Querystring: ContestRankingQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + const data = await service.getUserRating(username); + return reply.send(data); +} + +export async function getUserProfileHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserProfile(username); + return reply.send(data); +} + +export async function getUserDetailsHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserDetails(username); + return reply.send(data); +} + +export async function getUserBadgesHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserBadges(username); + return reply.send(data); +} + +export async function getUserSolvedHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserSolved(username); + return reply.send(data); +} + +export async function getUserContestHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserContest(username); + return reply.send(data); +} + +export async function getUserContestHistoryHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserContestHistory(username); + return reply.send(data); +} + +export async function getUserSubmissionHandler( + request: FastifyRequest<{ Params: { username: string }; Querystring: { limit?: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const limit = parseInt(request.query.limit || '20', 10); + const data = await service.getUserSubmission(username, limit); + return reply.send(data); +} + +export async function getUserAcSubmissionHandler( + request: FastifyRequest<{ Params: { username: string }; Querystring: { limit?: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const limit = parseInt(request.query.limit || '20', 10); + const data = await service.getUserAcSubmission(username, limit); + return reply.send(data); +} + +export async function getUserCalendarHandler( + request: FastifyRequest<{ Params: { username: string }; Querystring: { year?: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const year = parseInt(request.query.year || '0', 10); + const data = await service.getUserCalendar(username, year); + return reply.send(data); +} + +export async function getUserSkillHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserSkill(username); + return reply.send(data); +} + +export async function getUserLanguageHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserLanguage(username); + return reply.send(data); +} + +export async function getUserProgressHandler( + request: FastifyRequest<{ Params: { username: string } }>, + reply: FastifyReply +) { + const { username } = request.params; + const data = await service.getUserProgress(username); + return reply.send(data); +} diff --git a/src/modules/leetcode/index.ts b/src/modules/leetcode/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ffd915c739fd0cdf09f7de666c5467f82dada5 --- /dev/null +++ b/src/modules/leetcode/index.ts @@ -0,0 +1,2 @@ +export { default as leetcodePlugin } from './routes'; +export * from './types'; diff --git a/src/modules/leetcode/provider.ts b/src/modules/leetcode/provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..27c012a0e7d7151209873c683fd6afb41bdcb907 --- /dev/null +++ b/src/modules/leetcode/provider.ts @@ -0,0 +1,177 @@ +import { httpClient } from '../../shared/utils/http-client'; +import { + USER_CONTEST_RANKING_QUERY, + USER_RATING_QUERY, + CONTEST_HISTOGRAM_QUERY, + ALL_CONTESTS_QUERY, + DAILY_PROBLEM_QUERY, + SELECT_PROBLEM_QUERY, + PROBLEM_LIST_QUERY, + OFFICIAL_SOLUTION_QUERY, + TRENDING_DISCUSS_QUERY, + DISCUSS_TOPIC_QUERY, + DISCUSS_COMMENTS_QUERY, + USER_PROFILE_QUERY, + USER_PROFILE_CALENDAR_QUERY, + USER_QUESTION_PROGRESS_QUERY, + SKILL_STATS_QUERY, + LANGUAGE_STATS_QUERY, + AC_SUBMISSION_QUERY, + SUBMISSION_QUERY, + GET_USER_PROFILE_QUERY, + CONTEST_QUERY, + USER_BADGES_QUERY, + USER_SOLVED_QUERY, +} from './utils/queries'; +import type { + ContestRankingResponse, + ContestHistogramResponse, + Contest, + UserData, + DailyProblemData, + SelectProblemData, + ProblemSetQuestionListData, + UserProfileResponse, + UserRatingData, + OfficialSolutionData, + TrendingDiscussData, + DiscussTopicData, + DiscussCommentsData, + UserCalendarData, + UserQuestionProgressData, + SkillStatsData, + LanguageStatsData, + AcSubmissionsData, + SubmissionsData, + ContestData, +} from './types'; + +import { LEETCODE_API_URL, LEETCODE_HEADERS } from './constants'; + +async function leetcodeRequest(query: string, variables: object = {}): Promise { + const payload = { query, variables }; + const response = await httpClient.post(LEETCODE_API_URL, payload, { + headers: LEETCODE_HEADERS, + }); + + if (response.status !== 200) { + throw new Error(`LeetCode API returned status ${response.status}`); + } + + if (response.data.errors) { + throw new Error(response.data.errors[0].message); + } + + return response.data.data; +} + +// Existing functions +export async function fetchUserContestRanking(username: string): Promise { + return await leetcodeRequest(USER_CONTEST_RANKING_QUERY, { username }); +} + +export async function fetchUserRating(username: string): Promise { + return await leetcodeRequest(USER_RATING_QUERY, { username }); +} + +export async function fetchContestHistogram(): Promise { + return await leetcodeRequest(CONTEST_HISTOGRAM_QUERY); +} + +export async function fetchAllContests(): Promise { + const data = await leetcodeRequest<{ allContests: Contest[] }>(ALL_CONTESTS_QUERY); + return data.allContests; +} + +export async function fetchDailyProblem(): Promise { + return await leetcodeRequest(DAILY_PROBLEM_QUERY); +} + +export async function fetchSelectProblem(titleSlug: string): Promise { + return await leetcodeRequest(SELECT_PROBLEM_QUERY, { titleSlug }); +} + +const MAX_LEETCODE_LIMIT = 100; + +export async function fetchProblems(filters: { + categorySlug?: string; + limit?: number; + skip?: number; + filters?: any; +}): Promise { + const { categorySlug, limit = 20, skip = 0, filters: questionFilters } = filters; + const safeLimit = Math.min(limit, MAX_LEETCODE_LIMIT); + + return await leetcodeRequest(PROBLEM_LIST_QUERY, { + categorySlug, + limit: safeLimit, + skip, + filters: questionFilters, + }); +} + +export async function fetchOfficialSolution(titleSlug: string): Promise { + return await leetcodeRequest(OFFICIAL_SOLUTION_QUERY, { titleSlug }); +} + +export async function fetchTrendingDiscuss(first: number): Promise { + return await leetcodeRequest(TRENDING_DISCUSS_QUERY, { first }); +} + +export async function fetchDiscussTopic(topicId: number): Promise { + return await leetcodeRequest(DISCUSS_TOPIC_QUERY, { topicId }); +} + +export async function fetchDiscussComments(params: { + topicId: number; + orderBy?: string; + pageNo?: number; + numPerPage?: number; +}): Promise { + return await leetcodeRequest(DISCUSS_COMMENTS_QUERY, params); +} + +export async function fetchUserProfile(username: string): Promise { + return await leetcodeRequest(GET_USER_PROFILE_QUERY, { username }); +} + +export async function fetchUserData(username: string): Promise { + // This query is very large and might need optimization or splitting + return await leetcodeRequest(USER_PROFILE_QUERY, { username }); +} + +export async function fetchUserCalendar(username: string, year: number): Promise { + return await leetcodeRequest(USER_PROFILE_CALENDAR_QUERY, { username, year }); +} + +export async function fetchUserQuestionProgress(username: string): Promise { + return await leetcodeRequest(USER_QUESTION_PROGRESS_QUERY, { username }); +} + +export async function fetchSkillStats(username: string): Promise { + return await leetcodeRequest(SKILL_STATS_QUERY, { username }); +} + +export async function fetchLanguageStats(username: string): Promise { + return await leetcodeRequest(LANGUAGE_STATS_QUERY, { username }); +} + +export async function fetchAcSubmissions(username: string, limit: number): Promise { + return await leetcodeRequest(AC_SUBMISSION_QUERY, { username, limit }); +} + +export async function fetchSubmissions(username: string, limit: number): Promise { + return await leetcodeRequest(SUBMISSION_QUERY, { username, limit }); +} + +export async function fetchContestData(username: string): Promise { + return await leetcodeRequest(CONTEST_QUERY, { username }); +} + +export async function fetchUserBadges(username: string): Promise { + return await leetcodeRequest(USER_BADGES_QUERY, { username }); +} + +export async function fetchUserSolved(username: string): Promise { + return await leetcodeRequest(USER_SOLVED_QUERY, { username }); +} diff --git a/src/modules/leetcode/routes.ts b/src/modules/leetcode/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..2845c0105f2b31e47c518fb4fa971bf6fcc16a5d --- /dev/null +++ b/src/modules/leetcode/routes.ts @@ -0,0 +1,17 @@ +import { FastifyPluginAsync } from 'fastify'; +import userRoutes from './routes/user.routes'; +import contestRoutes from './routes/contest.routes'; +import problemRoutes from './routes/problem.routes'; +import discussionRoutes from './routes/discussion.routes'; + +const leetcodeRoutes: FastifyPluginAsync = async (fastify) => { + // Register specific category routes first + await fastify.register(contestRoutes, { prefix: '/contest' }); + await fastify.register(problemRoutes, { prefix: '/problem' }); + await fastify.register(discussionRoutes, { prefix: '/discuss' }); + + // Register user routes at root last to handle /api/v1/leetcode/{username} + await fastify.register(userRoutes, { prefix: '/' }); +}; + +export default leetcodeRoutes; diff --git a/src/modules/leetcode/routes/contest.routes.ts b/src/modules/leetcode/routes/contest.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..805fe8030b1a459a2fbcf36202669ec9afeccd4e --- /dev/null +++ b/src/modules/leetcode/routes/contest.routes.ts @@ -0,0 +1,45 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from '../handlers'; +import * as schemas from '../schemas'; +import validateUsername from '../../../shared/middlewares/validate'; +import type { ContestRankingQuery } from '../types'; + +const contestRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get<{ Querystring: ContestRankingQuery }>( + '/ranking', + { + preHandler: [validateUsername], + schema: schemas.contestRankingSchema, + }, + handlers.getContestRankingHandler + ); + + fastify.get<{ Querystring: ContestRankingQuery }>( + '/rating', + { + preHandler: [validateUsername], + schema: schemas.userRatingSchema, + }, + handlers.getUserRatingHandler + ); + + fastify.get( + '/histogram', + { schema: schemas.contestHistogramSchema }, + handlers.getContestHistogramHandler + ); + + fastify.get( + '/all', + { schema: schemas.allContestsSchema }, + handlers.getAllContestsHandler + ); + + fastify.get( + '/upcoming', + { schema: schemas.upcomingContestsSchema }, + handlers.getUpcomingContestsHandler + ); +}; + +export default contestRoutes; diff --git a/src/modules/leetcode/routes/discussion.routes.ts b/src/modules/leetcode/routes/discussion.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..31609f4fb633d7a530d48f5db4d6049f13155041 --- /dev/null +++ b/src/modules/leetcode/routes/discussion.routes.ts @@ -0,0 +1,25 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from '../handlers'; +import * as schemas from '../schemas'; + +const discussionRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get( + '/trending', + { schema: schemas.trendingDiscussSchema }, + handlers.getTrendingDiscussHandler + ); + + fastify.get<{ Params: { topicId: string } }>( + '/topic/:topicId', + { schema: schemas.discussionTopicSchema }, + handlers.getDiscussTopicHandler + ); + + fastify.get<{ Params: { topicId: string }; Querystring: any }>( + '/comments/:topicId', + { schema: schemas.discussionCommentsSchema }, + handlers.getDiscussCommentsHandler + ); +}; + +export default discussionRoutes; diff --git a/src/modules/leetcode/routes/problem.routes.ts b/src/modules/leetcode/routes/problem.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..89c293849de7e1b3e7504e0a3099fefc0bfacf7f --- /dev/null +++ b/src/modules/leetcode/routes/problem.routes.ts @@ -0,0 +1,31 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from '../handlers'; +import * as schemas from '../schemas'; + +const problemRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get( + '/daily', + { schema: schemas.dailyProblemSchema }, + handlers.getDailyProblemHandler + ); + + fastify.get<{ Querystring: { titleSlug: string; raw?: string } }>( + '/select', + { schema: schemas.selectProblemSchema }, + handlers.getSelectProblemHandler + ); + + fastify.get( + '/list', + { schema: schemas.listProblemsSchema }, + handlers.getProblemsHandler + ); + + fastify.get<{ Querystring: { titleSlug: string } }>( + '/official-solution', + { schema: schemas.officialSolutionSchema }, + handlers.getOfficialSolutionHandler + ); +}; + +export default problemRoutes; diff --git a/src/modules/leetcode/routes/user.routes.ts b/src/modules/leetcode/routes/user.routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..18322976dedf7fbb3469a86eeb455bec8789e5b2 --- /dev/null +++ b/src/modules/leetcode/routes/user.routes.ts @@ -0,0 +1,82 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from '../handlers'; +import * as schemas from '../schemas'; +import validateUsername from '../../../shared/middlewares/validate'; + +const userRoutes: FastifyPluginAsync = async (fastify) => { + fastify.addHook('preHandler', validateUsername); + + fastify.get<{ Params: { username: string } }>( + '/:username', + { schema: schemas.userDetailsSchema }, + handlers.getUserDetailsHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/badges', + { schema: schemas.userBadgesSchema }, + handlers.getUserBadgesHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/solved', + { schema: schemas.userSolvedSchema }, + handlers.getUserSolvedHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/contest', + { schema: schemas.userContestSchema }, + handlers.getUserContestHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/contest/history', + { schema: schemas.userContestHistorySchema }, + handlers.getUserContestHistoryHandler + ); + + fastify.get<{ Params: { username: string }; Querystring: { limit?: string } }>( + '/:username/submission', + { schema: schemas.userSubmissionSchema }, + handlers.getUserSubmissionHandler + ); + + fastify.get<{ Params: { username: string }; Querystring: { limit?: string } }>( + '/:username/accepted-submission', + { schema: { ...schemas.userSubmissionSchema, summary: 'Get user accepted submissions' } }, + handlers.getUserAcSubmissionHandler + ); + + fastify.get<{ Params: { username: string }; Querystring: { year?: string } }>( + '/:username/calendar', + { schema: schemas.userCalendarSchema }, + handlers.getUserCalendarHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/skill', + { schema: schemas.userSkillSchema }, + handlers.getUserSkillHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/profile', + { schema: schemas.userProfileSchema }, + handlers.getUserProfileHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/language', + { schema: schemas.userLanguageSchema }, + handlers.getUserLanguageHandler + ); + + fastify.get<{ Params: { username: string } }>( + '/:username/progress', + { schema: schemas.userProgressSchema }, + handlers.getUserProgressHandler + ); +}; + +export default userRoutes; diff --git a/src/modules/leetcode/schemas.ts b/src/modules/leetcode/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7fd22dea839ccc687f07c5052f4d8b11b3479fa --- /dev/null +++ b/src/modules/leetcode/schemas.ts @@ -0,0 +1,497 @@ +export const contestRankingSchema = { + description: 'Fetches LeetCode contest ranking for a user', + tags: ['LeetCode - Contests'], + querystring: { + type: 'object', + properties: { + username: { + type: 'string', + description: 'LeetCode username', + }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + userContestRanking: { + type: ['object', 'null'], + properties: { + attendedContestsCount: { type: 'number' }, + rating: { type: 'number' }, + globalRanking: { type: 'number' }, + totalParticipants: { type: 'number' }, + topPercentage: { type: 'number' }, + badge: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + userContestRankingHistory: { + type: 'array', + items: { + type: 'object', + properties: { + attended: { type: 'boolean' }, + rating: { type: 'number' }, + ranking: { type: 'number' }, + trendDirection: { type: 'string' }, + problemsSolved: { type: 'number' }, + totalProblems: { type: 'number' }, + finishTimeInSeconds: { type: 'number' }, + contest: { + type: 'object', + properties: { + title: { type: 'string' }, + startTime: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +export const userRatingSchema = { + description: 'Fetches LeetCode user rating', + tags: ['LeetCode - Contests'], + querystring: { + type: 'object', + properties: { + username: { + type: 'string', + description: 'LeetCode username', + }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + platform: { type: 'string' }, + rating: { type: 'number' }, + contests_participated: { type: 'number' }, + level: { type: 'string' }, + }, + }, + }, +}; + +export const contestHistogramSchema = { + description: 'Fetches LeetCode contest rating distribution (histogram)', + tags: ['LeetCode - Contests'], + response: { + 200: { + type: 'object', + properties: { + contestRatingHistogram: { + type: 'array', + items: { + type: 'object', + properties: { + userCount: { type: 'number' }, + ratingStart: { type: 'number' }, + ratingEnd: { type: 'number' }, + topPercentage: { type: 'number' }, + }, + }, + }, + }, + }, + }, +}; + +export const allContestsSchema = { + description: 'Fetches all LeetCode contests', + tags: ['LeetCode - Contests'], + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + titleSlug: { type: 'string' }, + startTime: { type: 'number' }, + duration: { type: 'number' }, + originStartTime: { type: 'number' }, + isVirtual: { type: 'boolean' }, + containsPremium: { type: 'boolean' }, + }, + }, + }, + }, +}; + +export const dailyProblemSchema = { + description: 'Fetches the LeetCode daily problem challenge', + tags: ['LeetCode - Problems'], + response: { + 200: { + type: 'object', + properties: { + questionLink: { type: 'string' }, + date: { type: 'string' }, + questionId: { type: 'string' }, + questionFrontendId: { type: 'string' }, + questionTitle: { type: 'string' }, + titleSlug: { type: 'string' }, + difficulty: { type: 'string' }, + isPaidOnly: { type: 'boolean' }, + question: { type: 'string' }, + exampleTestcases: { type: 'array', items: { type: 'string' } }, + topicTags: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, slug: { type: 'string' } } } }, + hints: { type: 'array', items: { type: 'string' } }, + likes: { type: 'number' }, + dislikes: { type: 'number' } + } + } + } +}; + +export const userDetailsSchema = { + description: 'Fetches comprehensive LeetCode user details', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + name: { type: 'string' }, + birthday: { type: 'string' }, + avatar: { type: 'string' }, + ranking: { type: 'number' }, + reputation: { type: 'number' }, + gitHub: { type: 'string' }, + twitter: { type: 'string' }, + linkedIN: { type: 'string' }, + website: { type: 'array', items: { type: 'string' } }, + country: { type: 'string' }, + company: { type: 'string' }, + school: { type: 'string' }, + skillTags: { type: 'array', items: { type: 'string' } }, + about: { type: 'string' } + } + } + } +}; + +export const userBadgesSchema = { + description: 'Fetches all badges earned by a LeetCode user', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + }, + response: { + 200: { + type: 'object', + properties: { + badgesCount: { type: 'number' }, + badges: { + type: 'array', + items: { + type: 'object', + properties: { + displayName: { type: 'string' }, + icon: { type: 'string' } + } + } + }, + upcomingBadges: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + icon: { type: 'string' } + } + } + }, + activeBadge: { + type: 'object', + properties: { + displayName: { type: 'string' }, + icon: { type: 'string' } + } + } + } + } + } +}; + +export const userSolvedSchema = { + description: 'Fetches numbers of problems solved by difficulty for a user', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + }, + response: { + 200: { + type: 'object', + properties: { + solvedProblem: { type: 'number' }, + easySolved: { type: 'number' }, + mediumSolved: { type: 'number' }, + hardSolved: { type: 'number' }, + totalSubmissionNum: { type: 'array', items: { type: 'object', properties: { difficulty: { type: 'string' }, count: { type: 'number' }, submissions: { type: 'number' } } } }, + acSubmissionNum: { type: 'array', items: { type: 'object', properties: { difficulty: { type: 'string' }, count: { type: 'number' }, submissions: { type: 'number' } } } } + } + } + } +}; + +export const trendingDiscussSchema = { + description: 'Fetches trending discussion topics from LeetCode', + tags: ['LeetCode - Discussion'], + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' }, + post: { + type: 'object', + properties: { + id: { type: 'number' }, + creationDate: { type: 'number' }, + contentPreview: { type: 'string' }, + author: { + type: 'object', + properties: { + username: { type: 'string' }, + profile: { + type: 'object', + properties: { + userAvatar: { type: 'string' } + } + } + } + } + } + } + } + } + } + } +}; + +export const upcomingContestsSchema = { + description: 'Fetches upcoming LeetCode contests', + tags: ['LeetCode - Contests'], + response: { + 200: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + titleSlug: { type: 'string' }, + startTime: { type: 'number' }, + duration: { type: 'number' } + } + } + } + } +}; + +export const userSubmissionSchema = { + description: 'Fetches recent submissions for a user', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + }, + querystring: { + type: 'object', + properties: { + limit: { type: 'string', description: 'Number of submissions to return' } + } + }, + response: { + 200: { + type: 'object', + properties: { + count: { type: 'number' }, + submission: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + titleSlug: { type: 'string' }, + timestamp: { type: 'string' }, + statusDisplay: { type: 'string' }, + lang: { type: 'string' } + } + } + } + } + } + } +}; + +export const userContestSchema = { + description: 'Get user contest details', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + } +}; + +export const userContestHistorySchema = { + description: 'Get user contest history', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + } +}; + +export const userCalendarSchema = { + description: 'Get user submission calendar', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + }, + querystring: { + type: 'object', + properties: { + year: { type: 'string', description: 'Year for the calendar' } + } + } +}; + +export const userSkillSchema = { + description: 'Get user skill stats', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + } +}; + +export const userProfileSchema = { + description: 'Get user profile', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + } +}; + +export const userLanguageSchema = { + description: 'Get user language stats', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + } +}; + +export const userProgressSchema = { + description: 'Get user progress', + tags: ['LeetCode - User'], + params: { + type: 'object', + properties: { + username: { type: 'string', description: 'LeetCode username' } + } + } +}; + +export const selectProblemSchema = { + description: 'Get a selected problem details', + tags: ['LeetCode - Problems'], + querystring: { + type: 'object', + properties: { + titleSlug: { type: 'string', description: 'Problem title slug' }, + raw: { type: 'string', description: 'Whether to return raw content' } + }, + required: ['titleSlug'] + } +}; + +export const listProblemsSchema = { + description: 'List all problems from problemset', + tags: ['LeetCode - Problems'], + querystring: { + type: 'object', + properties: { + offset: { type: 'string' }, + limit: { type: 'string' } + } + } +}; + +export const officialSolutionSchema = { + description: 'Get official solution for a problem', + tags: ['LeetCode - Problems'], + querystring: { + type: 'object', + properties: { + titleSlug: { type: 'string', description: 'Problem title slug' } + }, + required: ['titleSlug'] + } +}; + +export const discussionTopicSchema = { + description: 'Get discussion topic details', + tags: ['LeetCode - Discussion'], + params: { + type: 'object', + properties: { + topicId: { type: 'string', description: 'Discussion topic ID' } + } + } +}; + +export const discussionCommentsSchema = { + description: 'Get discussion comments for a topic', + tags: ['LeetCode - Discussion'], + params: { + type: 'object', + properties: { + topicId: { type: 'string', description: 'Discussion topic ID' } + } + } +}; diff --git a/src/modules/leetcode/services/contest.ts b/src/modules/leetcode/services/contest.ts new file mode 100644 index 0000000000000000000000000000000000000000..8db01d7385bcd074f4c842c3aa1ff9eceedc3163 --- /dev/null +++ b/src/modules/leetcode/services/contest.ts @@ -0,0 +1,45 @@ +import * as provider from '../provider'; +import * as formatters from '../utils/formatters'; +import { + ContestRankingResponse, + ContestHistogramResponse, + Contest, +} from '../types'; + +export async function getContestRankingInfo(username: string): Promise { + try { + const data = await provider.fetchUserContestRanking(username); + return formatters.formatContestRanking(data); + } catch (error: any) { + console.error(`LeetCode Error for ${username}:`, error.message); + throw new Error('Error fetching LeetCode contest ranking info'); + } +} + +export async function getContestHistogram(): Promise { + try { + return await provider.fetchContestHistogram(); + } catch (error: any) { + console.error('LeetCode Histogram Error:', error.message); + throw new Error('Error fetching LeetCode contest histogram'); + } +} + +export async function getAllContests(): Promise { + try { + return await provider.fetchAllContests(); + } catch (error: any) { + console.error('LeetCode Contests Error:', error.message); + throw new Error('Error fetching LeetCode contests'); + } +} + +export async function getUpcomingContests(): Promise { + try { + const contests = await provider.fetchAllContests(); + const now = Math.floor(Date.now() / 1000); + return contests.filter(c => c.startTime > now); + } catch (error: any) { + throw new Error('Error fetching upcoming LeetCode contests'); + } +} diff --git a/src/modules/leetcode/services/discussion.ts b/src/modules/leetcode/services/discussion.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8d51bf9b4a932a1d1570a7c9277019829d7e65e --- /dev/null +++ b/src/modules/leetcode/services/discussion.ts @@ -0,0 +1,28 @@ +import * as provider from '../provider'; + +export async function getTrendingDiscuss(first: number): Promise { + try { + return await provider.fetchTrendingDiscuss(first); + } catch (error: any) { + console.error('LeetCode Trending Discuss Error:', error.message); + throw new Error('Error fetching LeetCode trending discussions'); + } +} + +export async function getDiscussTopic(topicId: number): Promise { + try { + return await provider.fetchDiscussTopic(topicId); + } catch (error: any) { + console.error('LeetCode Discuss Topic Error:', error.message); + throw new Error('Error fetching LeetCode discuss topic'); + } +} + +export async function getDiscussComments(params: any): Promise { + try { + return await provider.fetchDiscussComments(params); + } catch (error: any) { + console.error('LeetCode Discuss Comments Error:', error.message); + throw new Error('Error fetching LeetCode discuss comments'); + } +} diff --git a/src/modules/leetcode/services/index.ts b/src/modules/leetcode/services/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..71082fda63e06e4aa7dd8129650c555007975171 --- /dev/null +++ b/src/modules/leetcode/services/index.ts @@ -0,0 +1,4 @@ +export * from './contest'; +export * from './user'; +export * from './problem'; +export * from './discussion'; diff --git a/src/modules/leetcode/services/problem.ts b/src/modules/leetcode/services/problem.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fe35feec74d5dbe01b7aa7d90c5be79fc68a7ea --- /dev/null +++ b/src/modules/leetcode/services/problem.ts @@ -0,0 +1,48 @@ +import * as provider from '../provider'; +import * as formatters from '../utils/formatters'; +import { + DailyProblemData, + SelectProblemData, + ProblemSetQuestionListData, +} from '../types'; + +export async function getDailyProblem(raw: boolean = false): Promise { + try { + const data = await provider.fetchDailyProblem(); + if (raw) return data; + return formatters.formatDailyData(data); + } catch (error: any) { + console.error('LeetCode Daily Problem Error:', error.message); + throw new Error('Error fetching LeetCode daily problem'); + } +} + +export async function getSelectProblem(titleSlug: string, raw: boolean = false): Promise { + try { + const data = await provider.fetchSelectProblem(titleSlug); + if (raw) return data; + return formatters.formatQuestionData(data); + } catch (error: any) { + console.error('LeetCode Select Problem Error:', error.message); + throw new Error('Error fetching LeetCode selected problem'); + } +} + +export async function getProblems(params: any): Promise { + try { + const data = await provider.fetchProblems(params); + return formatters.formatProblemsData(data); + } catch (error: any) { + console.error('LeetCode Problems Error:', error.message); + throw new Error('Error fetching LeetCode problems'); + } +} + +export async function getOfficialSolution(titleSlug: string): Promise { + try { + return await provider.fetchOfficialSolution(titleSlug); + } catch (error: any) { + console.error('LeetCode Official Solution Error:', error.message); + throw new Error('Error fetching LeetCode official solution'); + } +} diff --git a/src/modules/leetcode/services/user.ts b/src/modules/leetcode/services/user.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d34ceb394e01746fd8039b2c003aedd9f37af08 --- /dev/null +++ b/src/modules/leetcode/services/user.ts @@ -0,0 +1,142 @@ +import * as provider from '../provider'; +import * as formatters from '../utils/formatters'; +import { + UserRatingResponse, + UserProfileResponse, + UserData, +} from '../types'; + +export async function getUserRating(username: string): Promise { + try { + const data = await provider.fetchUserRating(username); + if (!data || !data.userContestRanking) { + throw new Error('User not found or no contest data'); + } + return formatters.formatUserRating(data, username); + } catch (error: any) { + console.error(`LeetCode Error for ${username}:`, error.message); + throw new Error(error.message || 'Error fetching LeetCode rating'); + } +} + +export async function getUserProfile(username: string): Promise { + try { + const data = await provider.fetchUserProfile(username); + return formatters.formatUserProfileData(data); + } catch (error: any) { + console.error('LeetCode User Profile Error:', error.message); + throw new Error('Error fetching LeetCode user profile'); + } +} + +export async function getUserDetails(username: string): Promise { + try { + const data = await provider.fetchUserData(username); + return formatters.formatUserData(data); + } catch (error: any) { + console.error('LeetCode User Details Error:', error.message); + throw new Error('Error fetching LeetCode user details'); + } +} + +export async function getUserBadges(username: string): Promise { + try { + const data = await provider.fetchUserBadges(username); + return formatters.formatBadgesData(data); + } catch (error: any) { + throw new Error('Error fetching LeetCode user badges'); + } +} + +export async function getUserSolved(username: string): Promise { + try { + const data = await provider.fetchUserSolved(username); + return formatters.formatSolvedProblemsData(data); + } catch (error: any) { + throw new Error('Error fetching LeetCode user solved problems'); + } +} + +export async function getUserContest(username: string): Promise { + try { + const data = await provider.fetchContestData(username); + return formatters.formatContestData(data); + } catch (error: any) { + throw new Error('Error fetching LeetCode user contest details'); + } +} + +export async function getUserContestHistory(username: string): Promise { + try { + const data = await provider.fetchUserContestRanking(username); + return { + count: data.userContestRankingHistory.length, + contestHistory: data.userContestRankingHistory, + }; + } catch (error: any) { + throw new Error('Error fetching LeetCode user contest history'); + } +} + +export async function getUserSubmission(username: string, limit: number): Promise { + try { + const data = await provider.fetchSubmissions(username, limit); + return { + count: data.recentSubmissionList.length, + submission: data.recentSubmissionList, + }; + } catch (error: any) { + throw new Error('Error fetching LeetCode user submissions'); + } +} + +export async function getUserAcSubmission(username: string, limit: number): Promise { + try { + const data = await provider.fetchAcSubmissions(username, limit); + return { + count: data.recentAcSubmissionList.length, + submission: data.recentAcSubmissionList, + }; + } catch (error: any) { + throw new Error('Error fetching LeetCode user AC submissions'); + } +} + +export async function getUserCalendar(username: string, year: number): Promise { + try { + const data = await provider.fetchUserCalendar(username, year); + // Calendar formatting might be needed + return data.matchedUser.userCalendar; + } catch (error: any) { + throw new Error('Error fetching LeetCode user calendar'); + } +} + +export async function getUserSkill(username: string): Promise { + try { + const data = await provider.fetchSkillStats(username); + return formatters.formatSkillStats({ matchedUser: data.matchedUser } as any); + } catch (error: any) { + throw new Error('Error fetching LeetCode user skills'); + } +} + +export async function getUserLanguage(username: string): Promise { + try { + const data = await provider.fetchLanguageStats(username); + return formatters.formatLanguageStats({ matchedUser: data.matchedUser } as any); + } catch (error: any) { + throw new Error('Error fetching LeetCode user languages'); + } +} + +export async function getUserProgress(username: string): Promise { + try { + const data = await provider.fetchUserQuestionProgress(username); + return { + numAcceptedQuestions: data.userProfileUserQuestionProgressV2, + }; + } catch (error: any) { + throw new Error('Error fetching LeetCode user progress'); + } +} diff --git a/src/modules/leetcode/types.ts b/src/modules/leetcode/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f01267b484f1c689166c38b043d01dae658f20b --- /dev/null +++ b/src/modules/leetcode/types.ts @@ -0,0 +1,478 @@ +export type Difficulty = 'All' | 'Easy' | 'Medium' | 'Hard'; + +export interface Badge { + name: string; + icon: string; +} + +export interface SkillStats { + tagName: string; + tagSlug: string; + problemsSolved: number; +} + +export interface UserDataProfile { + aboutMe: string; + company?: string; + countryName?: string; + realName: string; + birthday?: string; + userAvatar: string; + ranking: number; + reputation: number; + school?: string; + skillTags: string[]; + websites: string[]; +} + +export interface MatchedUser { + activeBadge: Badge; + badges: Badge[]; + githubUrl: string; + linkedinUrl?: string; + profile: UserDataProfile; + upcomingBadges: Badge[]; + username: string; + twitterUrl?: string; + userCalendar: { + activeYears: number[]; + streak: number; + totalActiveDays: number; + dccBadge: { + timestamp: number; + badge: { + name: string; + icon: string; + }; + }[]; + submissionCalendar: string; + }; + submitStats: { + totalSubmissionNum: { + difficulty: Difficulty; + count: number; + submissions: number; + }[]; + acSubmissionNum: { + difficulty: Difficulty; + count: number; + submissions: number; + }[]; + count: number; + }; + tagProblemCounts: { + fundamental: SkillStats[]; + intermediate: SkillStats[]; + advanced: SkillStats[]; + }; + languageProblemCount: { languageName: string; problemsSolved: number }[]; +} + +export interface UserData { + userContestRanking: null | { + attendedContestsCount: number; + badge: Badge; + globalRanking: number; + rating: number; + totalParticipants: number; + topPercentage: number; + }; + userContestRankingHistory: { + attended: boolean; + rating: number; + ranking: number; + trendDirection: string; + problemsSolved: number; + totalProblems: number; + finishTimeInSeconds: number; + contest: { + title: string; + startTime: string; + }; + }[]; + matchedUser: MatchedUser; + recentAcSubmissionList: object[]; + recentSubmissionList: Submission[]; + userProfileUserQuestionProgressV2: { count: number; difficulty: string }[]; +} + +export interface Submission { + title: string; + titleSlug: string; + timestamp: string; + statusDisplay: string; + lang: string; +} + +export interface Question { + content: string; + companyTagStats: string[]; + difficulty: Difficulty; + dislikes: number; + exampleTestcases: object[]; + hints: object[]; + isPaidOnly: boolean; + likes: number; + questionId: number; + questionFrontendId: number; + solution: string; + similarQuestions: object[]; + title: string; + titleSlug: string; + topicTags: string[]; +} + +export interface Contest { + title: string; + titleSlug: string; + startTime: number; + duration: number; + originStartTime: number; + isVirtual: boolean; + containsPremium: boolean; +} + +export interface ProblemSetQuestionListData { + problemsetQuestionList: { + total: number; + questions: object[]; + }; +} + +export interface DailyProblemData { + activeDailyCodingChallengeQuestion: { + date: string; + link: string; + question: Question; + }; +} + +export interface SelectProblemData { + question: Question; +} + +export interface TrendingDiscussionObject { + data: { + cachedTrendingCategoryTopics: { + id: number; + title: string; + post: { + id: number; + creationDate: number; + contentPreview: string; + author: { + username: string; + isActive: boolean; + profile: { + userAvatar: string; + }; + }; + }; + }[]; + }; +} + +export interface UserProfileResponse { + matchedUser: { + submitStats: { + acSubmissionNum: Array<{ count: number }>; + totalSubmissionNum: unknown; + }; + submissionCalendar: string; + profile: { + ranking: number; + reputation: number; + }; + contributions: { + points: number; + }; + }; + allQuestionsCount: Array<{ count: number }>; + recentSubmissionList: unknown[]; +} + +export interface ContestRatingHistogram { + userCount: number; + ratingStart: number; + ratingEnd: number; + topPercentage: number; +} + +export interface ContestHistogramResponse { + contestRatingHistogram: ContestRatingHistogram[]; +} + +export interface ContestRankingQuery { + username: string; +} + +export interface ContestRankingResponse { + userContestRanking: null | { + attendedContestsCount: number; + badge: Badge; + globalRanking: number; + rating: number; + totalParticipants: number; + topPercentage: number; + }; + userContestRankingHistory: { + attended: boolean; + rating: number; + ranking: number; + trendDirection: string; + problemsSolved: number; + totalProblems: number; + finishTimeInSeconds: number; + contest: { + title: string; + startTime: string; + }; + }[]; +} + +export interface UserRatingData { + userContestRanking: { + rating: number; + attendedContestsCount: number; + badge: { + name: string; + } | null; + globalRanking: number; + topPercentage: number; + } | null; +} + +export interface OfficialSolutionData { + question: { + solution: { + id: string; + title: string; + content: string; + contentTypeId: string; + paidOnly: boolean; + hasVideoSolution: boolean; + paidOnlyVideo: boolean; + canSeeDetail: boolean; + rating: { + count: number; + average: number; + userRating: { + score: number; + } | null; + }; + topic: { + id: number; + commentCount: number; + topLevelCommentCount: number; + viewCount: number; + subscribed: boolean; + solutionTags: { + name: string; + slug: string; + }[]; + post: { + id: number; + status: string; + creationDate: number; + author: { + username: string; + isActive: boolean; + profile: { + userAvatar: string; + reputation: number; + }; + }; + }; + }; + } | null; + }; +} + +export interface TrendingDiscussData { + cachedTrendingCategoryTopics: { + id: number; + title: string; + post: { + id: number; + creationDate: number; + contentPreview: string; + author: { + username: string; + isActive: boolean; + profile: { + userAvatar: string; + }; + }; + }; + }[]; +} + +export interface DiscussPost { + id: number; + voteCount: number; + voteStatus: number; + content: string; + updationDate: number; + creationDate: number; + status: string; + isHidden: boolean; + coinRewards: { + id: string; + score: number; + description: string; + date: string; + }[]; + author: { + isDiscussAdmin: boolean; + isDiscussStaff: boolean; + username: string; + nameColor: string | null; + activeBadge: { + displayName: string; + icon: string; + } | null; + profile: { + userAvatar: string; + reputation: number; + }; + isActive: boolean; + }; + authorIsModerator: boolean; + isOwnPost: boolean; +} + +export interface DiscussTopicData { + topic: { + id: number; + viewCount: number; + topLevelCommentCount: number; + subscribed: boolean; + title: string; + pinned: boolean; + tags: string[]; + hideFromTrending: boolean; + post: DiscussPost; + } | null; +} + +export interface DiscussCommentsData { + topicComments: { + data: { + id: string; + pinned: boolean; + pinnedBy: { + username: string; + } | null; + post: DiscussPost; + numChildren: number; + }[]; + }; +} + +export interface UserCalendarData { + matchedUser: { + userCalendar: { + activeYears: number[]; + streak: number; + totalActiveDays: number; + dccBadges: { + timestamp: number; + badge: { + name: string; + icon: string; + }; + }[]; + submissionCalendar: string; + }; + }; +} + +export interface UserQuestionProgressData { + userProfileUserQuestionProgressV2: { + numAcceptedQuestions: { + count: number; + difficulty: string; + }[]; + numFailedQuestions: { + count: number; + difficulty: string; + }[]; + numUntouchedQuestions: { + count: number; + difficulty: string; + }[]; + userSessionBeatsPercentage: { + difficulty: string; + percentage: number; + }[]; + }; +} + +export interface SkillStatsData { + matchedUser: { + tagProblemCounts: { + advanced: SkillStats[]; + intermediate: SkillStats[]; + fundamental: SkillStats[]; + }; + }; +} + +export interface LanguageStatsData { + matchedUser: { + languageProblemCount: { + languageName: string; + problemsSolved: number; + }[]; + }; +} + +export interface AcSubmissionsData { + recentAcSubmissionList: { + title: string; + titleSlug: string; + timestamp: string; + statusDisplay: string; + lang: string; + }[]; +} + +export interface SubmissionsData { + recentSubmissionList: Submission[]; +} + +export interface ContestData { + userContestRanking: { + attendedContestsCount: number; + rating: number; + globalRanking: number; + totalParticipants: number; + topPercentage: number; + badge: { + name: string; + } | null; + } | null; + userContestRankingHistory: { + attended: boolean; + rating: number; + ranking: number; + trendDirection: string; + problemsSolved: number; + totalProblems: number; + finishTimeInSeconds: number; + contest: { + title: string; + startTime: string; + }; + }[]; +} + +export interface UserRatingResponse { + username: string; + platform: string; + rating: number; + contests_participated: number; + level: string; + rank?: number; + top_percentage?: number; +} diff --git a/src/modules/leetcode/utils/constants.ts b/src/modules/leetcode/utils/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..11c54b57388ab5913da62d40f555bcf0437df94c --- /dev/null +++ b/src/modules/leetcode/utils/constants.ts @@ -0,0 +1,8 @@ +export function mapRating(rating: number): string { + if (rating >= 2135) return 'Guardian'; + else if (rating >= 1850) return 'Knight'; + else if (rating >= 1600) return 'Expert'; + else if (rating >= 1400) return 'Advanced'; + else if (rating >= 1200) return 'Intermediate'; + return 'Novice'; +} \ No newline at end of file diff --git a/src/modules/leetcode/utils/formatters.ts b/src/modules/leetcode/utils/formatters.ts new file mode 100644 index 0000000000000000000000000000000000000000..88adf8c0c1d0f05d2ce1d47f8804cdb53075df67 --- /dev/null +++ b/src/modules/leetcode/utils/formatters.ts @@ -0,0 +1,184 @@ +import { + ContestRankingResponse, + UserRatingResponse, + UserData, + DailyProblemData, + SelectProblemData, + ProblemSetQuestionListData, + UserProfileResponse, +} from '../types'; + +export function formatUserRating(data: any, username: string): UserRatingResponse { + if (!data || !data.userContestRanking) { + throw new Error('User not found or no contest data'); + } + + const ranking = data.userContestRanking; + const rating = Math.round(ranking.rating); + const level = ranking.badge?.name; + + + return { + username, + platform: 'leetcode', + rating, + contests_participated: ranking.attendedContestsCount, + level, + rank: ranking.globalRanking, + top_percentage: ranking.topPercentage, + }; +} + +export function formatContestRanking(data: any): ContestRankingResponse { + return { + userContestRanking: data.userContestRanking, + userContestRankingHistory: data.userContestRankingHistory || [], + }; +} + +export const formatUserData = (data: UserData) => ({ + username: data.matchedUser.username, + name: data.matchedUser.profile.realName, + birthday: data.matchedUser.profile.birthday, + avatar: data.matchedUser.profile.userAvatar, + ranking: data.matchedUser.profile.ranking, + reputation: data.matchedUser.profile.reputation, + gitHub: data.matchedUser.githubUrl, + twitter: data.matchedUser.twitterUrl, + linkedIN: data.matchedUser.linkedinUrl, + website: data.matchedUser.profile.websites, + country: data.matchedUser.profile.countryName, + company: data.matchedUser.profile.company, + school: data.matchedUser.profile.school, + skillTags: data.matchedUser.profile.skillTags, + about: data.matchedUser.profile.aboutMe, +}); + +export const formatBadgesData = (data: UserData) => ({ + badgesCount: data.matchedUser.badges.length, + badges: data.matchedUser.badges, + upcomingBadges: data.matchedUser.upcomingBadges, + activeBadge: data.matchedUser.activeBadge, +}); + +export const formatSolvedProblemsData = (data: UserData) => ({ + solvedProblem: data.matchedUser.submitStats.acSubmissionNum[0].count, + easySolved: data.matchedUser.submitStats.acSubmissionNum[1].count, + mediumSolved: data.matchedUser.submitStats.acSubmissionNum[2].count, + hardSolved: data.matchedUser.submitStats.acSubmissionNum[3].count, + totalSubmissionNum: data.matchedUser.submitStats.totalSubmissionNum, + acSubmissionNum: data.matchedUser.submitStats.acSubmissionNum, +}); + +export const formatSubmissionData = (data: UserData) => ({ + count: data.recentSubmissionList.length, + submission: data.recentSubmissionList, +}); + +export const formatAcSubmissionData = (data: UserData) => ({ + count: data.recentAcSubmissionList.length, + submission: data.recentAcSubmissionList, +}); + +export const formatSubmissionCalendarData = (data: UserData) => ({ + activeYears: data.matchedUser.userCalendar.activeYears, + streak: data.matchedUser.userCalendar.streak, + totalActiveDays: data.matchedUser.userCalendar.totalActiveDays, + dccBadges: data.matchedUser.userCalendar.dccBadge, + submissionCalendar: data.matchedUser.userCalendar.submissionCalendar, +}); + +export const formatSkillStats = (data: UserData) => ({ + fundamental: data.matchedUser.tagProblemCounts.fundamental, + intermediate: data.matchedUser.tagProblemCounts.intermediate, + advanced: data.matchedUser.tagProblemCounts.advanced, +}); + +export const formatLanguageStats = (data: UserData) => ({ + languageProblemCount: data.matchedUser.languageProblemCount, +}); + +export const formatProgressStats = (data: UserData) => ({ + numAcceptedQuestions: data.userProfileUserQuestionProgressV2, +}); + +export const formatUserProfileData = (data: UserProfileResponse) => { + return { + totalSolved: data.matchedUser.submitStats.acSubmissionNum[0].count, + totalSubmissions: data.matchedUser.submitStats.totalSubmissionNum, + totalQuestions: data.allQuestionsCount[0].count, + easySolved: data.matchedUser.submitStats.acSubmissionNum[1].count, + totalEasy: data.allQuestionsCount[1].count, + mediumSolved: data.matchedUser.submitStats.acSubmissionNum[2].count, + totalMedium: data.allQuestionsCount[2].count, + hardSolved: data.matchedUser.submitStats.acSubmissionNum[3].count, + totalHard: data.allQuestionsCount[3].count, + ranking: data.matchedUser.profile.ranking, + contributionPoint: data.matchedUser.contributions.points, + reputation: data.matchedUser.profile.reputation, + submissionCalendar: JSON.parse(data.matchedUser.submissionCalendar), + recentSubmissions: data.recentSubmissionList, + matchedUserStats: data.matchedUser.submitStats, + }; +}; + +export const formatDailyData = (data: DailyProblemData) => ({ + questionLink: `https://leetcode.com${data.activeDailyCodingChallengeQuestion.link}`, + date: data.activeDailyCodingChallengeQuestion.date, + questionId: data.activeDailyCodingChallengeQuestion.question.questionId, + questionFrontendId: + data.activeDailyCodingChallengeQuestion.question.questionFrontendId, + questionTitle: data.activeDailyCodingChallengeQuestion.question.title, + titleSlug: data.activeDailyCodingChallengeQuestion.question.titleSlug, + difficulty: data.activeDailyCodingChallengeQuestion.question.difficulty, + isPaidOnly: data.activeDailyCodingChallengeQuestion.question.isPaidOnly, + question: data.activeDailyCodingChallengeQuestion.question.content, + exampleTestcases: + data.activeDailyCodingChallengeQuestion.question.exampleTestcases, + topicTags: data.activeDailyCodingChallengeQuestion.question.topicTags, + hints: data.activeDailyCodingChallengeQuestion.question.hints, + solution: data.activeDailyCodingChallengeQuestion.question.solution, + companyTagStats: + data.activeDailyCodingChallengeQuestion.question.companyTagStats, + likes: data.activeDailyCodingChallengeQuestion.question.likes, + dislikes: data.activeDailyCodingChallengeQuestion.question.dislikes, + similarQuestions: + data.activeDailyCodingChallengeQuestion.question.similarQuestions, +}); + +export const formatQuestionData = (data: SelectProblemData) => ({ + link: `https://leetcode.com/problems/${data.question.titleSlug}`, + questionId: data.question.questionId, + questionFrontendId: data.question.questionFrontendId, + questionTitle: data.question.title, + titleSlug: data.question.titleSlug, + difficulty: data.question.difficulty, + isPaidOnly: data.question.isPaidOnly, + question: data.question.content, + exampleTestcases: data.question.exampleTestcases, + topicTags: data.question.topicTags, + hints: data.question.hints, + solution: data.question.solution, + companyTagStats: data.question.companyTagStats, + likes: data.question.likes, + dislikes: data.question.dislikes, + similarQuestions: data.question.similarQuestions, +}); + +export const formatProblemsData = (data: ProblemSetQuestionListData) => ({ + totalQuestions: data.problemsetQuestionList.total, + count: data.problemsetQuestionList.questions.length, + problemsetQuestionList: data.problemsetQuestionList.questions, +}); + +export const formatContestData = (data: any) => ({ + contestAttend: data.userContestRanking?.attendedContestsCount, + contestRating: data.userContestRanking?.rating, + contestGlobalRanking: data.userContestRanking?.globalRanking, + totalParticipants: data.userContestRanking?.totalParticipants, + contestTopPercentage: data.userContestRanking?.topPercentage, + contestBadges: data.userContestRanking?.badge, + contestParticipation: data.userContestRankingHistory.filter( + (obj: any) => obj.attended === true, + ), +}); diff --git a/src/modules/leetcode/utils/queries.ts b/src/modules/leetcode/utils/queries.ts new file mode 100644 index 0000000000000000000000000000000000000000..c162943df2344ff06d8c952b49407cbad3584624 --- /dev/null +++ b/src/modules/leetcode/utils/queries.ts @@ -0,0 +1,712 @@ +export const USER_CONTEST_RANKING_QUERY = ` + query userContestRankingInfo($username: String!) { + userContestRanking(username: $username) { + attendedContestsCount + rating + globalRanking + totalParticipants + topPercentage + badge { + name + } + } + userContestRankingHistory(username: $username) { + attended + trendDirection + problemsSolved + totalProblems + finishTimeInSeconds + rating + ranking + contest { + title + startTime + } + } + } +`; + +export const USER_RATING_QUERY = ` + query getUserContestRanking($username: String!) { + userContestRanking(username: $username) { + rating + attendedContestsCount + badge { + name + } + globalRanking + topPercentage + } + } +`; + +export const CONTEST_HISTOGRAM_QUERY = ` + query contestRatingHistogram { + contestRatingHistogram { + userCount + ratingStart + ratingEnd + topPercentage + } + } +`; + +export const ALL_CONTESTS_QUERY = ` + query allContests { + allContests { + title + titleSlug + startTime + duration + originStartTime + isVirtual + containsPremium + } + } +`; + +export const DAILY_PROBLEM_QUERY = ` + query getDailyProblem { + activeDailyCodingChallengeQuestion { + date + link + question { + questionId + questionFrontendId + boundTopicId + title + titleSlug + content + translatedTitle + translatedContent + isPaidOnly + difficulty + likes + dislikes + isLiked + similarQuestions + exampleTestcases + contributors { + username + profileUrl + avatarUrl + } + topicTags { + name + slug + translatedName + } + companyTagStats + codeSnippets { + lang + langSlug + code + } + stats + hints + solution { + id + canSeeDetail + paidOnly + hasVideoSolution + paidOnlyVideo + } + status + sampleTestCase + metaData + judgerAvailable + judgeType + mysqlSchemas + enableRunCode + enableTestMode + enableDebugger + envInfo + libraryUrl + adminUrl + challengeQuestion { + id + date + incompleteChallengeCount + streakCount + type + } + note + } + } +}`; + +export const SELECT_PROBLEM_QUERY = ` +query selectProblem($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionId + questionFrontendId + boundTopicId + title + titleSlug + content + translatedTitle + translatedContent + isPaidOnly + difficulty + likes + dislikes + isLiked + similarQuestions + exampleTestcases + contributors { + username + profileUrl + avatarUrl + } + topicTags { + name + slug + translatedName + } + companyTagStats + codeSnippets { + lang + langSlug + code + } + stats + hints + solution { + id + canSeeDetail + paidOnly + hasVideoSolution + paidOnlyVideo + } + status + sampleTestCase + metaData + judgerAvailable + judgeType + mysqlSchemas + enableRunCode + enableTestMode + enableDebugger + envInfo + libraryUrl + adminUrl + challengeQuestion { + id + date + incompleteChallengeCount + streakCount + type + } + note + } +}`; + +export const PROBLEM_LIST_QUERY = ` + query getProblems($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList: questionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + total: totalNum + questions: data { + acRate + difficulty + freqBar + questionFrontendId + isFavor + isPaidOnly + status + title + titleSlug + topicTags { + name + id + slug + } + hasSolution + hasVideoSolution + } + } +}`; + +export const OFFICIAL_SOLUTION_QUERY = ` + query OfficialSolution($titleSlug: String!) { + question(titleSlug: $titleSlug) { + solution { + id + title + content + contentTypeId + paidOnly + hasVideoSolution + paidOnlyVideo + canSeeDetail + rating { + count + average + userRating { + score + } + } + topic { + id + commentCount + topLevelCommentCount + viewCount + subscribed + solutionTags { + name + slug + } + post { + id + status + creationDate + author { + username + isActive + profile { + userAvatar + reputation + } + } + } + } + } + } + } +`; + +export const TRENDING_DISCUSS_QUERY = ` + query trendingDiscuss($first: Int!) { + cachedTrendingCategoryTopics(first: $first) { + id + title + post { + id + creationDate + contentPreview + author { + username + isActive + profile { + userAvatar + } + } + } + } + } +`; + +export const DISCUSS_TOPIC_QUERY = ` + query DiscussTopic($topicId: Int!) { + topic(id: $topicId) { + id + viewCount + topLevelCommentCount + subscribed + title + pinned + tags + hideFromTrending + post { + ...DiscussPost + } + } + } + + fragment DiscussPost on PostNode { + id + voteCount + voteStatus + content + updationDate + creationDate + status + isHidden + coinRewards { + ...CoinReward + } + author { + isDiscussAdmin + isDiscussStaff + username + nameColor + activeBadge { + displayName + icon + } + profile { + userAvatar + reputation + } + isActive + } + authorIsModerator + isOwnPost + } + + fragment CoinReward on ScoreNode { + id + score + description + date + } +`; + +export const DISCUSS_COMMENTS_QUERY = ` + query discussComments($topicId: Int!, $orderBy: String = "newest_to_oldest", $pageNo: Int = 1, $numPerPage: Int = 10) { + topicComments(topicId: $topicId, orderBy: $orderBy, pageNo: $pageNo, numPerPage: $numPerPage) { + data { + id + pinned + pinnedBy { + username + } + post { + ...DiscussPost + } + numChildren + } + } + } + + fragment DiscussPost on PostNode { + id + voteCount + voteStatus + content + updationDate + creationDate + status + isHidden + coinRewards { + ...CoinReward + } + author { + isDiscussAdmin + isDiscussStaff + username + nameColor + activeBadge { + displayName + icon + } + profile { + userAvatar + reputation + } + isActive + } + authorIsModerator + isOwnPost + } + + fragment CoinReward on ScoreNode { + id + score + description + date + } +`; + +export const USER_PROFILE_QUERY = ` +query getUserProfile($username: String!) { + allQuestionsCount { + difficulty + count + } + matchedUser(username: $username) { + username + githubUrl + twitterUrl + linkedinUrl + contributions { + points + questionCount + testcaseCount + } + profile { + realName + userAvatar + birthday + ranking + reputation + websites + countryName + company + school + skillTags + aboutMe + starRating + } + badges { + id + displayName + icon + creationDate + } + upcomingBadges { + name + icon + } + activeBadge { + id + displayName + icon + creationDate + } + submitStats { + totalSubmissionNum { + difficulty + count + submissions + } + acSubmissionNum { + difficulty + count + submissions + } + } + submissionCalendar + } + recentSubmissionList(username: $username, limit: 20) { + title + titleSlug + timestamp + statusDisplay + lang + } +}`; + +export const USER_PROFILE_CALENDAR_QUERY = ` + query UserProfileCalendar($username: String!, $year: Int!) { + matchedUser(username: $username) { + userCalendar(year: $year) { + activeYears + streak + totalActiveDays + dccBadges { + timestamp + badge { + name + icon + } + } + submissionCalendar + } + } + } +`; + +export const USER_QUESTION_PROGRESS_QUERY = ` + query userProfileUserQuestionProgressV2($username: String!) { + userProfileUserQuestionProgressV2(userSlug: $username) { + numAcceptedQuestions { + count + difficulty + } + numFailedQuestions { + count + difficulty + } + numUntouchedQuestions { + count + difficulty + } + userSessionBeatsPercentage { + difficulty + percentage + } + } + } +`; + +export const SKILL_STATS_QUERY = ` + query skillStats($username: String!) { + matchedUser(username: $username) { + tagProblemCounts { + advanced { + tagName + tagSlug + problemsSolved + } + intermediate { + tagName + tagSlug + problemsSolved + } + fundamental { + tagName + tagSlug + problemsSolved + } + } + } + } +`; + +export const LANGUAGE_STATS_QUERY = ` + query languageStats($username: String!) { + matchedUser(username: $username) { + languageProblemCount { + languageName + problemsSolved + } + } + } +`; + +export const AC_SUBMISSION_QUERY = ` +query getACSubmissions ($username: String!, $limit: Int) { + recentAcSubmissionList(username: $username, limit: $limit) { + title + titleSlug + timestamp + statusDisplay + lang + } +}`; + +export const SUBMISSION_QUERY = ` +query getRecentSubmissions($username: String!, $limit: Int) { + recentSubmissionList(username: $username, limit: $limit) { + title + titleSlug + timestamp + statusDisplay + lang + } +}`; + +export const GET_USER_PROFILE_QUERY = ` + query getUserProfile($username: String!) { + allQuestionsCount { + difficulty + count + } + matchedUser(username: $username) { + contributions { + points + } + profile { + reputation + ranking + } + submissionCalendar + submitStats { + acSubmissionNum { + difficulty + count + submissions + } + totalSubmissionNum { + difficulty + count + submissions + } + } + } + recentSubmissionList(username: $username) { + title + titleSlug + timestamp + statusDisplay + lang + __typename + } + matchedUserStats: matchedUser(username: $username) { + submitStats: submitStatsGlobal { + acSubmissionNum { + difficulty + count + submissions + __typename + } + totalSubmissionNum { + difficulty + count + submissions + __typename + } + __typename + } + } + } +`; + +export const CONTEST_QUERY = ` +query getUserContestRanking ($username: String!) { + userContestRanking(username: $username) { + attendedContestsCount + rating + globalRanking + totalParticipants + topPercentage + badge { + name + } + } + userContestRankingHistory(username: $username) { + attended + rating + ranking + trendDirection + problemsSolved + totalProblems + finishTimeInSeconds + contest { + title + startTime + } + } +}`; + +export const USER_BADGES_QUERY = ` + query userBadges($username: String!) { + matchedUser(username: $username) { + badges { + id + displayName + icon + creationDate + } + upcomingBadges { + name + icon + } + activeBadge { + id + displayName + icon + creationDate + } + } + } +`; + +export const USER_SOLVED_QUERY = ` + query userSolved($username: String!) { + allQuestionsCount { + difficulty + count + } + matchedUser(username: $username) { + submitStats { + acSubmissionNum { + difficulty + count + submissions + } + totalSubmissionNum { + difficulty + count + submissions + } + } + } + } +`; + diff --git a/src/modules/mcp/index.ts b/src/modules/mcp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..67dd10d9929ee4024c33a760b978e756beb95466 --- /dev/null +++ b/src/modules/mcp/index.ts @@ -0,0 +1,43 @@ +import { FastifyPluginAsync } from 'fastify'; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { streamableHttp, Sessions } from "fastify-mcp"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; + +import { registerAllTools } from "./tools"; +import { registerAllPrompts } from "./prompts"; +import { registerAllResources } from "./resources"; + +function createMcpServer(): Server { + const mcp = new McpServer({ + name: "vortex", + version: "1.0.0", + }); + + registerAllTools(mcp); + registerAllPrompts(mcp); + registerAllResources(mcp); + + console.log("[MCP] Server initialized with tools, prompts and resources"); + + return mcp.server; +} + +export const mcpPlugin: FastifyPluginAsync = async (fastify) => { + fastify.addHook('onRoute', (routeOptions) => { + if (routeOptions.url.startsWith('/mcp')) { + routeOptions.schema = { + ...routeOptions.schema, + tags: ['MCP'], + }; + } + }); + + fastify.register(streamableHttp, { + stateful: true, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + createServer: createMcpServer, + }); +}; + diff --git a/src/modules/mcp/prompts/index.ts b/src/modules/mcp/prompts/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e562a066a622f3a4b96ed510a3f438812017cbf --- /dev/null +++ b/src/modules/mcp/prompts/index.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Registers all MCP prompts. + * + * @param mcp - The MCP Server instance to register prompts with + */ +export function registerAllPrompts(mcp: McpServer): void { + // Contest performance summary prompt + mcp.prompt( + "contest-summary", + "Request a professional summary of a user's competitive programming performance across platforms", + { username: z.string().describe("The username to analyze") }, + async ({ username }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Analyze the contest ratings for "${username}" across multiple platforms (LeetCode, Codeforces, AtCoder, CodeChef, GeeksforGeeks) and provide a professional summary of their competitive programming strengths, rating progression, and standing.` + } + } + ] + }) + ); + + // Comparison prompt + mcp.prompt( + "compare-users", + "Request a comparison between two competitive programmers across platforms", + { + user1: z.string().describe("First username to compare"), + user2: z.string().describe("Second username to compare") + }, + async ({ user1, user2 }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Compare the competitive programming profiles of "${user1}" and "${user2}". Analyze their ratings, contest participation, problem-solving statistics, and strengths across available platforms.` + } + } + ] + }) + ); + + // Improvement suggestions prompt + mcp.prompt( + "improvement-tips", + "Request personalized improvement suggestions based on a user's competitive programming profile", + { username: z.string().describe("The username to analyze for improvement tips") }, + async ({ username }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Based on the competitive programming profile of "${username}", analyze their problem-solving patterns, rating trends, and skill distribution. Provide actionable improvement suggestions to help them advance to the next level.` + } + } + ] + }) + ); +} diff --git a/src/modules/mcp/resources/index.ts b/src/modules/mcp/resources/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..807ea55d0b5699553c15108ad307ae4544e9a5c5 --- /dev/null +++ b/src/modules/mcp/resources/index.ts @@ -0,0 +1,115 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Registers all MCP resources. + * + * @param mcp - The MCP Server instance to register resources with + */ +export function registerAllResources(mcp: McpServer): void { + // API Manifest resource + mcp.resource( + "api-manifest", + "manifest://contest-api", + { + title: "vortex Manifest", + description: "Metadata and platform support details for this vortex MCP instance" + }, + async () => ({ + contents: [ + { + uri: "manifest://contest-api", + text: JSON.stringify({ + name: "vortex", + version: "1.0.0", + description: "A comprehensive API for competitive programming data aggregation", + supportedPlatforms: [ + { + name: "LeetCode", + endpoints: ["rating", "profile", "details", "badges", "solved", "contest", "submissions", "calendar", "skills", "languages", "progress"] + }, + { + name: "Codeforces", + endpoints: ["rating", "history", "status", "blogs", "problems", "contests", "standings", "hacks"] + }, + { + name: "AtCoder", + endpoints: ["rating", "history", "standings", "results", "virtual"] + }, + { + name: "CodeChef", + endpoints: ["rating"] + }, + { + name: "GeeksforGeeks", + endpoints: ["rating", "submissions", "posts", "events", "leaderboard"] + } + ], + mcpEndpoints: { + mcp: "/mcp", + health: "/health", + docs: "/docs" + } + }, null, 2), + mimeType: "application/json" + } + ] + }) + ); + + // Platforms overview resource + mcp.resource( + "platforms-overview", + "docs://platforms", + { + title: "Supported Platforms Overview", + description: "Detailed information about each supported competitive programming platform" + }, + async () => ({ + contents: [ + { + uri: "docs://platforms", + text: JSON.stringify({ + platforms: [ + { + name: "LeetCode", + website: "https://leetcode.com", + focus: "Technical interview preparation and weekly/biweekly contests", + ratingSystem: "Algorithm rating (starts at 1500)", + toolCount: 13 + }, + { + name: "Codeforces", + website: "https://codeforces.com", + focus: "Competitive programming contests and problem archive", + ratingSystem: "Elo-like rating with colored ranks (Newbie to Legendary Grandmaster)", + toolCount: 15 + }, + { + name: "AtCoder", + website: "https://atcoder.jp", + focus: "Japanese competitive programming platform with ABC/ARC/AGC contests", + ratingSystem: "Rating with colored ranks (Gray to Red)", + toolCount: 5 + }, + { + name: "CodeChef", + website: "https://www.codechef.com", + focus: "Monthly challenges and competitive programming", + ratingSystem: "Star-based rating (1★ to 7★)", + toolCount: 1 + }, + { + name: "GeeksforGeeks", + website: "https://www.geeksforgeeks.org", + focus: "DSA learning and coding contests", + ratingSystem: "Score-based with star levels", + toolCount: 5 + } + ] + }, null, 2), + mimeType: "application/json" + } + ] + }) + ); +} diff --git a/src/modules/mcp/tools/atcoder.ts b/src/modules/mcp/tools/atcoder.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9a08214696d7de1d474ec455d8e0fff5ba022c2 --- /dev/null +++ b/src/modules/mcp/tools/atcoder.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { safeToolHandler } from "./base"; +import * as service from "../../atcoder/service"; + +/** + * Registers all AtCoder-related MCP tools. + */ +export function register(mcp: McpServer): void { + // Get user rating + mcp.tool( + "atcoder_get_rating", + "Fetches an AtCoder user's rating, rank, max rating, country, birth year, avatar, and complete rating history.", + { username: z.string().describe("The AtCoder username") }, + safeToolHandler(({ username }) => service.getUserRating(username)) + ); + + // Get user contest history + mcp.tool( + "atcoder_get_history", + "Fetches the complete contest participation history for an AtCoder user with performance and rating changes.", + { username: z.string().describe("The AtCoder username") }, + safeToolHandler(({ username }) => service.getUserHistory(username)) + ); + + // Get contest standings + mcp.tool( + "atcoder_get_contest_standings", + "Fetches the standings/leaderboard for a specific AtCoder contest showing all participants' scores and rankings.", + { + contestId: z.string().describe("The AtCoder contest ID (e.g., 'abc300')"), + extended: z.boolean().optional().default(false).describe("Include extended statistics (default: false)") + }, + safeToolHandler(({ contestId, extended }) => service.getContestStandings(contestId, extended)) + ); + + // Get contest results + mcp.tool( + "atcoder_get_contest_results", + "Fetches the final results and rating changes for all participants in a specific AtCoder contest.", + { contestId: z.string().describe("The AtCoder contest ID (e.g., 'abc300')") }, + safeToolHandler(({ contestId }) => service.getContestResults(contestId)) + ); + + // Get virtual standings + mcp.tool( + "atcoder_get_virtual_standings", + "Fetches the virtual contest standings for an AtCoder contest, showing ghost participants.", + { + contestId: z.string().describe("The AtCoder contest ID (e.g., 'abc300')"), + showGhost: z.boolean().optional().default(true).describe("Include ghost participants (default: true)") + }, + safeToolHandler(({ contestId, showGhost }) => service.getVirtualStandings(contestId, showGhost)) + ); +} diff --git a/src/modules/mcp/tools/base.ts b/src/modules/mcp/tools/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..52c13c79ccb862a61d1a2bd2e975b6ba9994b2f9 --- /dev/null +++ b/src/modules/mcp/tools/base.ts @@ -0,0 +1,33 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Creates a safe tool handler that wraps async operations with standardized error handling. + * Automatically catches exceptions and formats them as MCP-compliant error responses. + * + * @param handler - The async function that performs the actual tool logic + * @returns A wrapped handler with consistent error handling + */ +export function safeToolHandler( + handler: (args: T) => Promise +): (args: T) => Promise<{ isError?: boolean; content: { type: "text"; text: string }[];[x: string]: unknown }> { + return async (args: T) => { + try { + const result = await handler(args); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "An unexpected error occurred"; + console.error("[MCP Tool Error]", message); + return { + isError: true, + content: [{ type: "text" as const, text: `Error: ${message}` }], + }; + } + }; +} + +/** + * Type definition for a tool registration function. + */ +export type ToolRegistrar = (mcp: McpServer) => void; diff --git a/src/modules/mcp/tools/codechef.ts b/src/modules/mcp/tools/codechef.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a82408a5eb8abe154160d1a9ffcb265d0dbda2e --- /dev/null +++ b/src/modules/mcp/tools/codechef.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { safeToolHandler } from "./base"; +import * as service from "../../codechef/service"; + +/** + * Registers all CodeChef-related MCP tools. + */ +export function register(mcp: McpServer): void { + // Get user rating + mcp.tool( + "codechef_get_rating", + "Fetches a CodeChef user's current rating, star level (1★ to 7★), and maximum rating achieved.", + { username: z.string().describe("The CodeChef username") }, + safeToolHandler(({ username }) => service.getUserRating(username)) + ); +} diff --git a/src/modules/mcp/tools/codeforces.ts b/src/modules/mcp/tools/codeforces.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f2cb6cfef532a096766cf93e0fdcaf47852f9c1 --- /dev/null +++ b/src/modules/mcp/tools/codeforces.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { safeToolHandler } from "./base"; +import * as service from "../../codeforces/service"; + +/** + * Registers all Codeforces-related MCP tools. + */ +export function register(mcp: McpServer): void { + // Get user rating + mcp.tool( + "codeforces_get_rating", + "Fetches a Codeforces user's current rating, rank, max rating, contribution score, and avatar URL.", + { username: z.string().describe("The Codeforces handle (username)") }, + safeToolHandler(({ username }) => service.getUserRating(username)) + ); + + // Get contest history + mcp.tool( + "codeforces_get_contest_history", + "Fetches the complete contest participation history for a Codeforces user, including rating changes after each contest.", + { username: z.string().describe("The Codeforces handle (username)") }, + safeToolHandler(({ username }) => service.getContestHistory(username)) + ); + + // Get user status (submissions) + mcp.tool( + "codeforces_get_user_status", + "Fetches recent submissions for a Codeforces user with verdict, problem info, and submission details.", + { + username: z.string().describe("The Codeforces handle (username)"), + from: z.number().optional().default(1).describe("1-based index of the first submission to return (default: 1)"), + count: z.number().optional().default(10).describe("Number of submissions to return (default: 10)") + }, + safeToolHandler(({ username, from, count }) => service.getUserStatus(username, from, count)) + ); + + // Get user blog entries + mcp.tool( + "codeforces_get_user_blogs", + "Fetches all blog entries authored by a Codeforces user, including titles, ratings, and creation times.", + { username: z.string().describe("The Codeforces handle (username)") }, + safeToolHandler(({ username }) => service.getUserBlogs(username)) + ); + + // Get solved problems + mcp.tool( + "codeforces_get_solved_problems", + "Fetches all problems solved by a Codeforces user, including problem ratings, tags, and direct links.", + { username: z.string().describe("The Codeforces handle (username)") }, + safeToolHandler(({ username }) => service.getSolvedProblems(username)) + ); + + // Get contests list + mcp.tool( + "codeforces_get_contests", + "Fetches the list of all Codeforces contests (past and upcoming), optionally including gym contests.", + { gym: z.boolean().optional().default(false).describe("If true, include gym contests (default: false)") }, + safeToolHandler(({ gym }) => service.getContests(gym)) + ); + + // Get recent actions + mcp.tool( + "codeforces_get_recent_actions", + "Fetches recent actions on Codeforces such as new blog entries and comments.", + { maxCount: z.number().optional().default(20).describe("Maximum number of actions to return (default: 20, max: 100)") }, + safeToolHandler(({ maxCount }) => service.getRecentActions(maxCount)) + ); + + // Get problemset problems + mcp.tool( + "codeforces_get_problems", + "Fetches all problems from the Codeforces problemset, optionally filtered by tags.", + { tags: z.string().optional().describe("Semicolon-separated tags to filter problems (e.g., 'dp;greedy')") }, + safeToolHandler(({ tags }) => service.getProblems(tags)) + ); + + // Get contest standings + mcp.tool( + "codeforces_get_contest_standings", + "Fetches the standings/leaderboard for a specific Codeforces contest with participant rankings and scores.", + { + contestId: z.number().describe("The numeric ID of the contest"), + from: z.number().optional().describe("1-based index of the first standing row"), + count: z.number().optional().describe("Number of standings rows to return"), + handles: z.string().optional().describe("Semicolon-separated handles to filter the standings"), + room: z.number().optional().describe("Room number to filter standings"), + showUnofficial: z.boolean().optional().describe("Include unofficial participants") + }, + safeToolHandler(({ contestId, from, count, handles, room, showUnofficial }) => + service.getContestStandings(contestId, from, count, handles, room, showUnofficial) + ) + ); + + // Get contest rating changes + mcp.tool( + "codeforces_get_contest_rating_changes", + "Fetches the rating changes for all participants in a specific Codeforces contest.", + { contestId: z.number().describe("The numeric ID of the contest") }, + safeToolHandler(({ contestId }) => service.getContestRatingChanges(contestId)) + ); + + // Get contest hacks + mcp.tool( + "codeforces_get_contest_hacks", + "Fetches all successful and unsuccessful hacks that occurred during a Codeforces contest.", + { contestId: z.number().describe("The numeric ID of the contest") }, + safeToolHandler(({ contestId }) => service.getContestHacks(contestId)) + ); + + // Get contest status (submissions) + mcp.tool( + "codeforces_get_contest_status", + "Fetches submissions made in a specific Codeforces contest, optionally filtered by a handle.", + { + contestId: z.number().describe("The numeric ID of the contest"), + handle: z.string().optional().describe("Optional handle to filter submissions"), + from: z.number().optional().describe("1-based index of the first submission"), + count: z.number().optional().describe("Number of submissions to return") + }, + safeToolHandler(({ contestId, handle, from, count }) => + service.getContestStatus(contestId, handle, from, count) + ) + ); + + // Get problemset recent status + mcp.tool( + "codeforces_get_problemset_recent_status", + "Fetches the most recent submissions across the entire Codeforces problemset.", + { count: z.number().describe("Number of recent submissions to return (max: 1000)") }, + safeToolHandler(({ count }) => service.getProblemsetRecentStatus(count)) + ); + + // Get blog entry + mcp.tool( + "codeforces_get_blog_entry", + "Fetches the full content of a specific Codeforces blog entry by its ID.", + { blogEntryId: z.number().describe("The numeric ID of the blog entry") }, + safeToolHandler(({ blogEntryId }) => service.getBlogEntry(blogEntryId)) + ); + + // Get blog comments + mcp.tool( + "codeforces_get_blog_comments", + "Fetches all comments on a specific Codeforces blog entry.", + { blogEntryId: z.number().describe("The numeric ID of the blog entry") }, + safeToolHandler(({ blogEntryId }) => service.getBlogComments(blogEntryId)) + ); +} diff --git a/src/modules/mcp/tools/general.ts b/src/modules/mcp/tools/general.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b1165bfe5204563e7bb21128878a3a386a4944d --- /dev/null +++ b/src/modules/mcp/tools/general.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Registers general utility MCP tools. + */ +export function register(mcp: McpServer): void { + // Hello greeting tool + mcp.tool( + "hello", + "A simple greeting tool that welcomes users to the vortex MCP Server. Use this to verify connectivity.", + { name: z.string().describe("The name of the user to greet") }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}! Welcome to the vortex MCP Server. I can help you fetch competitive programming data from LeetCode, Codeforces, AtCoder, CodeChef, and GeeksforGeeks.` }], + }) + ); + + // Server info tool + mcp.tool( + "server_info", + "Returns information about the vortex MCP Server including version, supported platforms, and available API categories.", + {}, + async () => ({ + content: [{ + type: "text", + text: JSON.stringify({ + name: "vortex MCP Server", + version: "1.0.0", + supportedPlatforms: [ + { name: "LeetCode", tools: 22 }, + { name: "Codeforces", tools: 15 }, + { name: "AtCoder", tools: 5 }, + { name: "CodeChef", tools: 1 }, + { name: "GeeksforGeeks", tools: 5 } + ], + totalTools: 50, + capabilities: ["User ratings", "Contest history", "Submissions", "Problem sets", "Leaderboards"] + }, null, 2) + }], + }) + ); +} diff --git a/src/modules/mcp/tools/gfg.ts b/src/modules/mcp/tools/gfg.ts new file mode 100644 index 0000000000000000000000000000000000000000..e224cf1688532057c35cabc56d0cbfddb74b5af7 --- /dev/null +++ b/src/modules/mcp/tools/gfg.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { safeToolHandler } from "./base"; +import * as service from "../../gfg/service"; + +/** + * Registers all GeeksforGeeks (GFG)-related MCP tools. + */ +export function register(mcp: McpServer): void { + // Get user rating + mcp.tool( + "gfg_get_rating", + "Fetches a GeeksforGeeks user's coding score/rating and star level.", + { username: z.string().describe("The GFG username/handle") }, + safeToolHandler(({ username }) => service.getUserRating(username)) + ); + + // Get user submissions + mcp.tool( + "gfg_get_submissions", + "Fetches problem submissions for a GeeksforGeeks user with optional time-based filtering.", + { + handle: z.string().describe("The GFG username/handle"), + requestType: z.string().optional().describe("Type of request filter"), + year: z.string().optional().describe("Filter by year (e.g., '2024')"), + month: z.string().optional().describe("Filter by month (e.g., '01' for January)") + }, + safeToolHandler(({ handle, requestType, year, month }) => + service.getUserSubmissions({ handle, requestType, year, month }) + ) + ); + + // Get user posts + mcp.tool( + "gfg_get_posts", + "Fetches articles and posts authored by a GeeksforGeeks user.", + { + username: z.string().describe("The GFG username"), + fetch_type: z.string().optional().describe("Type of posts to fetch"), + page: z.number().optional().describe("Page number for pagination") + }, + safeToolHandler(({ username, fetch_type, page }) => + service.getUserPosts({ username, fetch_type, page }) + ) + ); + + // Get promotional events + mcp.tool( + "gfg_get_promotional_events", + "Fetches current and upcoming promotional events/contests on GeeksforGeeks.", + { + page_source: z.string().describe("The page source identifier"), + user_country_code: z.string().optional().describe("User's country code for localized events") + }, + safeToolHandler(({ page_source, user_country_code }) => + service.getPromotionalEvents({ page_source, user_country_code }) + ) + ); + + // Get contest leaderboard + mcp.tool( + "gfg_get_contest_leaderboard", + "Fetches the leaderboard for GeeksforGeeks coding contests.", + { + leaderboard_type: z.number().optional().describe("Type of leaderboard (numeric)"), + page: z.number().optional().describe("Page number for pagination"), + year_month: z.string().optional().describe("Filter by year and month (e.g., '2024-01')") + }, + safeToolHandler(({ leaderboard_type, page, year_month }) => + service.getContestLeaderboard({ leaderboard_type, page, year_month }) + ) + ); +} diff --git a/src/modules/mcp/tools/index.ts b/src/modules/mcp/tools/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7466bc8aee9ae5d3d53f7f287920b3e44e30d185 --- /dev/null +++ b/src/modules/mcp/tools/index.ts @@ -0,0 +1,25 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as leetcodeTools from "./leetcode"; +import * as codeforcesTools from "./codeforces"; +import * as atcoderTools from "./atcoder"; +import * as codechefTools from "./codechef"; +import * as gfgTools from "./gfg"; +import * as generalTools from "./general"; + +/** + * Registers all MCP tools from all platform modules. + * This is the single entry point for tool registration. + * + * @param mcp - The MCP Server instance to register tools with + */ +export function registerAllTools(mcp: McpServer): void { + // Register platform-specific tools + leetcodeTools.register(mcp); + codeforcesTools.register(mcp); + atcoderTools.register(mcp); + codechefTools.register(mcp); + gfgTools.register(mcp); + + // Register general utility tools + generalTools.register(mcp); +} diff --git a/src/modules/mcp/tools/leetcode.ts b/src/modules/mcp/tools/leetcode.ts new file mode 100644 index 0000000000000000000000000000000000000000..7646bd122a45dbf5c8454cedde34073c31ef9ccc --- /dev/null +++ b/src/modules/mcp/tools/leetcode.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { safeToolHandler } from "./base"; +import * as userService from "../../leetcode/services/user"; +import * as contestService from "../../leetcode/services/contest"; +import * as problemService from "../../leetcode/services/problem"; +import * as discussionService from "../../leetcode/services/discussion"; + +export function register(mcp: McpServer): void { + // ==================== USER TOOLS ==================== + + // Get user contest rating + mcp.tool( + "leetcode_get_rating", + "Fetches the LeetCode contest rating for a user, including their global ranking, attended contests count, and rating percentile.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserRating(username)) + ); + + // Get user profile + mcp.tool( + "leetcode_get_profile", + "Fetches the complete LeetCode profile for a user, including basic info, company, school, skills, and social links.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserProfile(username)) + ); + + // Get user details + mcp.tool( + "leetcode_get_details", + "Fetches detailed user information from LeetCode including profile data, submission stats, and problem solving summary.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserDetails(username)) + ); + + // Get user badges + mcp.tool( + "leetcode_get_badges", + "Fetches all badges earned by a LeetCode user, including annual badges and special achievements.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserBadges(username)) + ); + + // Get user solved problems + mcp.tool( + "leetcode_get_solved", + "Fetches the solved problems count for a LeetCode user, broken down by difficulty (Easy, Medium, Hard).", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserSolved(username)) + ); + + // Get user contest ranking + mcp.tool( + "leetcode_get_contest_ranking", + "Fetches detailed contest ranking information for a LeetCode user, including rating, ranking, and top percentage.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserContest(username)) + ); + + // Get user contest history + mcp.tool( + "leetcode_get_contest_history", + "Fetches the complete contest participation history for a LeetCode user, including each contest's ranking and rating changes.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserContestHistory(username)) + ); + + // Get user submissions + mcp.tool( + "leetcode_get_submissions", + "Fetches recent submissions for a LeetCode user, including problem title, status, language, and timestamp.", + { + username: z.string().describe("The LeetCode username to look up"), + limit: z.number().optional().default(20).describe("Maximum number of submissions to return (default: 20)") + }, + safeToolHandler(({ username, limit }) => userService.getUserSubmission(username, limit)) + ); + + // Get user accepted submissions + mcp.tool( + "leetcode_get_ac_submissions", + "Fetches recent accepted (AC) submissions for a LeetCode user, filtered to only show successful solutions.", + { + username: z.string().describe("The LeetCode username to look up"), + limit: z.number().optional().default(20).describe("Maximum number of AC submissions to return (default: 20)") + }, + safeToolHandler(({ username, limit }) => userService.getUserAcSubmission(username, limit)) + ); + + // Get user calendar/activity + mcp.tool( + "leetcode_get_calendar", + "Fetches the submission calendar/heatmap data for a LeetCode user for a specific year, showing daily activity.", + { + username: z.string().describe("The LeetCode username to look up"), + year: z.number().describe("The year to fetch calendar data for (e.g., 2024)") + }, + safeToolHandler(({ username, year }) => userService.getUserCalendar(username, year)) + ); + + // Get user skill stats + mcp.tool( + "leetcode_get_skill_stats", + "Fetches the skill/tag statistics for a LeetCode user, showing proficiency in different problem categories like DP, Graph, etc.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserSkill(username)) + ); + + // Get user language stats + mcp.tool( + "leetcode_get_languages", + "Fetches the programming language statistics for a LeetCode user, showing problems solved per language.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserLanguage(username)) + ); + + // Get user progress + mcp.tool( + "leetcode_get_progress", + "Fetches the overall problem-solving progress for a LeetCode user across all categories.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => userService.getUserProgress(username)) + ); + + // ==================== CONTEST TOOLS ==================== + + // Get contest ranking info + mcp.tool( + "leetcode_get_contest_ranking_info", + "Fetches detailed contest ranking information including rating, global rank, and percentile for a user.", + { username: z.string().describe("The LeetCode username to look up") }, + safeToolHandler(({ username }) => contestService.getContestRankingInfo(username)) + ); + + // Get contest histogram + mcp.tool( + "leetcode_get_contest_histogram", + "Fetches the contest rating distribution histogram showing how many users are at each rating level.", + {}, + safeToolHandler(() => contestService.getContestHistogram()) + ); + + // Get all contests + mcp.tool( + "leetcode_get_all_contests", + "Fetches a list of all LeetCode contests including past weekly and biweekly contests with their details.", + {}, + safeToolHandler(() => contestService.getAllContests()) + ); + + // Get upcoming contests + mcp.tool( + "leetcode_get_upcoming_contests", + "Fetches a list of upcoming LeetCode contests that haven't started yet.", + {}, + safeToolHandler(() => contestService.getUpcomingContests()) + ); + + // ==================== PROBLEM TOOLS ==================== + + // Get daily problem + mcp.tool( + "leetcode_get_daily_problem", + "Fetches today's LeetCode Daily Challenge problem with its details, difficulty, and constraints.", + { + raw: z.boolean().optional().default(false).describe("Return raw API response if true (default: false)") + }, + safeToolHandler(({ raw }) => problemService.getDailyProblem(raw)) + ); + + // Get specific problem by slug + mcp.tool( + "leetcode_get_problem", + "Fetches detailed information about a specific LeetCode problem by its title slug (e.g., 'two-sum').", + { + titleSlug: z.string().describe("The URL slug of the problem (e.g., 'two-sum', 'add-two-numbers')"), + raw: z.boolean().optional().default(false).describe("Return raw API response if true (default: false)") + }, + safeToolHandler(({ titleSlug, raw }) => problemService.getSelectProblem(titleSlug, raw)) + ); + + // Get problems list + mcp.tool( + "leetcode_get_problems", + "Fetches a filtered list of LeetCode problems with pagination, difficulty filter, and category support.", + { + limit: z.number().optional().default(20).describe("Number of problems to return"), + skip: z.number().optional().default(0).describe("Number of problems to skip (for pagination)"), + categorySlug: z.string().optional().describe("Category slug to filter by (e.g., 'algorithms', 'database')"), + filters: z.object({ + difficulty: z.string().optional().describe("Difficulty filter: 'EASY', 'MEDIUM', or 'HARD'"), + tags: z.array(z.string()).optional().describe("Array of tag slugs to filter by") + }).optional().describe("Optional filters object") + }, + safeToolHandler((params) => problemService.getProblems(params)) + ); + + // Get official solution + mcp.tool( + "leetcode_get_official_solution", + "Fetches the official LeetCode solution/editorial for a specific problem.", + { + titleSlug: z.string().describe("The URL slug of the problem (e.g., 'two-sum')") + }, + safeToolHandler(({ titleSlug }) => problemService.getOfficialSolution(titleSlug)) + ); + + // ==================== DISCUSSION TOOLS ==================== + + // Get trending discussions + mcp.tool( + "leetcode_get_trending_discussions", + "Fetches the top trending discussions from LeetCode's discussion forum.", + { + first: z.number().optional().default(10).describe("Number of trending discussions to return (default: 10)") + }, + safeToolHandler(({ first }) => discussionService.getTrendingDiscuss(first)) + ); + + // Get specific discussion topic + mcp.tool( + "leetcode_get_discussion_topic", + "Fetches a specific discussion topic by its ID, including the full content and metadata.", + { + topicId: z.number().describe("The numeric ID of the discussion topic") + }, + safeToolHandler(({ topicId }) => discussionService.getDiscussTopic(topicId)) + ); + + // Get discussion comments + mcp.tool( + "leetcode_get_discussion_comments", + "Fetches comments/replies on a specific LeetCode discussion topic.", + { + topicId: z.number().describe("The numeric ID of the discussion topic"), + orderBy: z.string().optional().default("hot").describe("Order by: 'hot', 'newest', or 'oldest'"), + pageNo: z.number().optional().default(1).describe("Page number for pagination"), + numPerPage: z.number().optional().default(10).describe("Number of comments per page") + }, + safeToolHandler((params) => discussionService.getDiscussComments(params)) + ); +} diff --git a/src/modules/ratings/handlers.ts b/src/modules/ratings/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..3585d6b58852ea137fd7cb34bf4383c79da3a426 --- /dev/null +++ b/src/modules/ratings/handlers.ts @@ -0,0 +1,39 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import * as service from './service'; +import type { PlatformQuery, PlatformParams } from './types'; + +export async function getAllRatingsHandler( + request: FastifyRequest<{ Querystring: PlatformQuery }>, + reply: FastifyReply +) { + const { username } = request.query; + + try { + const data = await service.getAllRatings(username); + return reply.send(data); + } catch (error: any) { + return reply.status(500).send({ + error: 'Error fetching ratings', + }); + } +} + +export async function getPlatformRatingHandler( + request: FastifyRequest<{ Querystring: PlatformQuery; Params: PlatformParams }>, + reply: FastifyReply +) { + const { username } = request.query; + const { platform } = request.params; + + try { + const rating = await service.getPlatformRating(platform, username); + return reply.send({ platform, username, ...rating }); + } catch (error: any) { + if (error.message === 'Invalid platform') { + return reply.status(400).send({ error: 'Invalid platform' }); + } + return reply.status(500).send({ + error: `Error fetching ${platform} rating`, + }); + } +} diff --git a/src/modules/ratings/index.ts b/src/modules/ratings/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa20e49f850f8cb6f9aef3287788b106a57f6a2b --- /dev/null +++ b/src/modules/ratings/index.ts @@ -0,0 +1,2 @@ +export { default as ratingsPlugin } from './routes'; +export * from './types'; diff --git a/src/modules/ratings/routes.ts b/src/modules/ratings/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..e57a986593386bda52763e12decb7a8272c1762c --- /dev/null +++ b/src/modules/ratings/routes.ts @@ -0,0 +1,27 @@ +import { FastifyPluginAsync } from 'fastify'; +import * as handlers from './handlers'; +import * as schemas from './schemas'; +import validateUsername from '../../shared/middlewares/validate'; +import type { PlatformQuery, PlatformParams } from './types'; + +const ratingsRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get<{ Querystring: PlatformQuery }>( + '/', + { + preHandler: [validateUsername], + schema: schemas.allRatingsSchema, + }, + handlers.getAllRatingsHandler + ); + + fastify.get<{ Querystring: PlatformQuery; Params: PlatformParams }>( + '/:platform', + { + preHandler: [validateUsername], + schema: schemas.platformRatingSchema, + }, + handlers.getPlatformRatingHandler + ); +}; + +export default ratingsRoutes; diff --git a/src/modules/ratings/schemas.ts b/src/modules/ratings/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..c809dff45ce9088bb8ac0d35d64ebc026301c20a --- /dev/null +++ b/src/modules/ratings/schemas.ts @@ -0,0 +1,55 @@ +export const allRatingsSchema = { + summary: 'Get All Platform Ratings', + description: 'Fetches aggregate ratings and stats from all supported platforms (LeetCode, Codeforces, CodeChef, AtCoder, GFG) for a given username.', + tags: ['Ratings'], + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Unified username for all platforms' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + codeforces: { type: 'object', additionalProperties: true }, + codechef: { type: 'object', additionalProperties: true }, + leetcode: { type: 'object', additionalProperties: true }, + atcoder: { type: 'object', additionalProperties: true }, + gfg: { type: 'object', additionalProperties: true } + } + } + } +}; + +export const platformRatingSchema = { + summary: 'Get Specific Platform Rating', + description: 'Fetches the rating and statistics for a single specified competitive programming platform.', + tags: ['Ratings'], + params: { + type: 'object', + properties: { + platform: { + type: 'string', + description: 'The platform slug', + enum: ['codeforces', 'codechef', 'leetcode', 'atcoder', 'gfg'] + }, + }, + required: ['platform'] + }, + querystring: { + type: 'object', + properties: { + username: { type: 'string', description: 'Username on the target platform' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + additionalProperties: true + } + } +}; diff --git a/src/modules/ratings/service.ts b/src/modules/ratings/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..551777f37a12e7c09ba103e852ac6721dc30baff --- /dev/null +++ b/src/modules/ratings/service.ts @@ -0,0 +1,47 @@ +import * as codeforcesService from '../codeforces/service'; +import * as codechefService from '../codechef/service'; +import * as leetcodeService from '../leetcode/services'; +import * as atcoderService from '../atcoder/service'; +import * as gfgService from '../gfg/service'; +import type { AggregateRatingsResponse } from './types'; +import { withTimeout } from '../../shared/utils/timeout'; + +const PLATFORM_TIMEOUT = 5000; // 5 seconds per platform + +// Get ratings from all platforms +export async function getAllRatings(username: string): Promise { + const [codeforces, codechef, leetcode, atcoder, gfg] = await Promise.allSettled([ + withTimeout(codeforcesService.getUserRating(username), PLATFORM_TIMEOUT, 'Codeforces timeout'), + withTimeout(codechefService.getUserRating(username), PLATFORM_TIMEOUT, 'CodeChef timeout'), + withTimeout(leetcodeService.getUserRating(username), PLATFORM_TIMEOUT, 'LeetCode timeout'), + withTimeout(atcoderService.getUserRating(username), PLATFORM_TIMEOUT, 'AtCoder timeout'), + withTimeout(gfgService.getUserRating(username), PLATFORM_TIMEOUT, 'GFG timeout'), + ]); + + return { + username, + codeforces: codeforces.status === 'fulfilled' ? codeforces.value : { error: 'Failed to fetch' }, + codechef: codechef.status === 'fulfilled' ? codechef.value : { error: 'Failed to fetch' }, + leetcode: leetcode.status === 'fulfilled' ? leetcode.value : { error: 'Failed to fetch' }, + atcoder: atcoder.status === 'fulfilled' ? atcoder.value : { error: 'Failed to fetch' }, + gfg: gfg.status === 'fulfilled' ? gfg.value : { error: 'Failed to fetch' }, + }; +} + +// Get rating for a specific platform +export async function getPlatformRating(platform: string, username: string): Promise { + switch (platform.toLowerCase()) { + case 'codeforces': + return await codeforcesService.getUserRating(username); + case 'codechef': + return await codechefService.getUserRating(username); + case 'leetcode': + return await leetcodeService.getUserRating(username); + case 'atcoder': + return await atcoderService.getUserRating(username); + case 'gfg': + return await gfgService.getUserRating(username); + default: + throw new Error('Invalid platform'); + } +} diff --git a/src/modules/ratings/types.ts b/src/modules/ratings/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..548f6b990ae19e6b057491212db4627139c2396d --- /dev/null +++ b/src/modules/ratings/types.ts @@ -0,0 +1,16 @@ +export interface AggregateRatingsResponse { + username: string; + codeforces: any; + codechef: any; + leetcode: any; + atcoder: any; + gfg: any; +} + +export interface PlatformQuery { + username: string; +} + +export interface PlatformParams { + platform: string; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..e23ae070afcec43411554930d4ea8cd882d26e00 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,16 @@ +import { buildApp } from "./app"; +import config from "./config/env"; + +const start = async () => { + try { + const fastify = await buildApp(); + await fastify.listen({ port: config.port, host: config.host }); + console.log(`Server running on http://localhost:${config.port}`); + console.log(`Swagger docs available at http://localhost:${config.port}/docs`); + } catch (err) { + console.error(err); + process.exit(1); + } +}; + +start(); diff --git a/src/shared/middlewares/validate.ts b/src/shared/middlewares/validate.ts new file mode 100644 index 0000000000000000000000000000000000000000..a99fb16d2b72edc35255a6b886f2a6b4e74476d5 --- /dev/null +++ b/src/shared/middlewares/validate.ts @@ -0,0 +1,17 @@ +import { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from "fastify"; + +const validateUsername = ( + request: FastifyRequest, + reply: FastifyReply, + done: HookHandlerDoneFunction +) => { + const username = (request.query as any).username || (request.params as any).username; + if (!username || typeof username !== "string" || username.trim() === "") { + reply.status(400).send({ error: "Invalid or missing username" }); + return; + } + request.username = username.trim(); + done(); +}; + +export default validateUsername; diff --git a/src/shared/utils/http-client.ts b/src/shared/utils/http-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a1711bc60e050348a9053890ae8882e4eb7cdc7 --- /dev/null +++ b/src/shared/utils/http-client.ts @@ -0,0 +1,123 @@ +import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +// Configuration for Retry and Circuit Breaker +const MAX_RETRIES = 2; +const RETRY_DELAY = 1000; // ms +const FAILURE_THRESHOLD = 5; +const RESET_TIMEOUT = 30000; // 30 seconds +const RATE_LIMIT_DELAY = 100; // 100ms minimum between requests per host + +interface CustomConfig extends InternalAxiosRequestConfig { + _retryCount?: number; +} + +// Circuit Breaker & Rate Limit State +const circuitStates: Record = {}; + +function getHost(url?: string): string { + if (!url) return 'unknown'; + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export const httpClient: AxiosInstance = axios.create({ + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request Interceptor: Circuit Breaker & Rate Limit Check +httpClient.interceptors.request.use( + async (config) => { + const host = getHost(config.url); + + if (!circuitStates[host]) { + circuitStates[host] = { status: 'CLOSED', failures: 0 }; + } + + const state = circuitStates[host]; + + // Circuit Breaker Check + if (state.status === 'OPEN') { + const now = Date.now(); + if (now - (state.lastFailure || 0) > RESET_TIMEOUT) { + state.status = 'HALF_OPEN'; + } else { + throw new Error(`Circuit breaker is OPEN for ${host}`); + } + } + + // Rate Limit Check (Simple Delay) + const now = Date.now(); + if (state.lastRequest && (now - state.lastRequest < RATE_LIMIT_DELAY)) { + const waitTime = RATE_LIMIT_DELAY - (now - state.lastRequest); + await new Promise(resolve => setTimeout(resolve, waitTime)); + } + state.lastRequest = Date.now(); + + return config; + }, + (error) => Promise.reject(error) +); + +// Response Interceptor: Retry Logic and Circuit Breaker Tracking +httpClient.interceptors.response.use( + (response) => { + const host = getHost(response.config.url); + if (circuitStates[host]) { + circuitStates[host].failures = 0; + circuitStates[host].status = 'CLOSED'; + } + return response; + }, + async (error) => { + const config = error.config as CustomConfig; + const host = getHost(config?.url); + + if (!circuitStates[host]) { + circuitStates[host] = { status: 'CLOSED', failures: 0 }; + } + + const state = circuitStates[host]; + + // Track failures for Circuit Breaker + state.failures += 1; + state.lastFailure = Date.now(); + + if (state.failures >= FAILURE_THRESHOLD) { + state.status = 'OPEN'; + } + + // Retry Logic for 503 or Network Errors + if (config && (error.response?.status === 503 || !error.response)) { + config._retryCount = config._retryCount || 0; + + if (config._retryCount < MAX_RETRIES) { + config._retryCount += 1; + const delay = RETRY_DELAY * config._retryCount; + + // Exponential backoff + await new Promise((resolve) => setTimeout(resolve, delay)); + return httpClient(config); + } + } + + // Simplified error messages + if (error.response?.status === 503) { + error.message = 'Service temporarily unavailable'; + } else if (error.code === 'ECONNABORTED') { + error.message = 'Request timed out'; + } + + return Promise.reject(error); + } +); diff --git a/src/shared/utils/timeout.ts b/src/shared/utils/timeout.ts new file mode 100644 index 0000000000000000000000000000000000000000..a134ca441e05e3835cb005f1e19553f9a3a43695 --- /dev/null +++ b/src/shared/utils/timeout.ts @@ -0,0 +1,9 @@ +export async function withTimeout(promise: Promise, timeoutMs: number, errorMessage: string): Promise { + const timeout = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(errorMessage)); + }, timeoutMs); + }); + + return Promise.race([promise, timeout]); +} diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..66edc3546210c1eb30c99354848f608d3649ff8b --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,22 @@ +export interface SuccessResponse { + success: true; + data: T; +} + +export interface ErrorResponse { + success: false; + error: string; + message?: string; +} + +export interface PlatformRating { + username: string; + platform: string; + rating: number | string; + level?: string; + contests_participated?: number; + max_rating?: number | string; + max_level?: string; +} + +export type ApiResponse = SuccessResponse | ErrorResponse; diff --git a/src/types/fastify.d.ts b/src/types/fastify.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..24f387432bafbb7d96be026d68683b8e7fb066de --- /dev/null +++ b/src/types/fastify.d.ts @@ -0,0 +1,7 @@ +import 'fastify'; + +declare module 'fastify' { + interface FastifyRequest { + username?: string; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c46c6892b49a393f735e3505d336e2b6c9b6f8d --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export * from './fastify'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..53f07b9c4ca9f1c03defdc94e6d24362c79ff3eb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "rootDir": "./src", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "baseUrl": "./src", + "paths": { + "@modules/*": [ + "modules/*" + ], + "@shared/*": [ + "shared/*" + ], + "@types/*": [ + "types/*" + ], + "@config/*": [ + "config/*" + ] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ], + "ts-node": { + "files": true + } +} \ No newline at end of file