/**
* Yuque API Client with enhanced error handling and configuration support
* 增强的语雀API客户端,支持更好的错误处理和配置
*/
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import type { Config } from './config/index.js';
// Enhanced type definitions with better documentation
export interface YuqueUser {
id: number;
type: string;
login: string;
name: string;
description?: string;
avatar_url: string;
public: number;
followers_count: number;
following_count: number;
created_at: string;
updated_at: string;
}
export interface YuqueRepo {
id: number;
type: string;
slug: string;
name: string;
description?: string;
namespace: string;
public: boolean;
items_count: number;
likes_count: number;
watches_count: number;
created_at: string;
updated_at: string;
user?: YuqueUser;
}
export interface YuqueDoc {
id: number;
type: string;
slug: string;
title: string;
description?: string;
format: 'markdown' | 'lake' | 'html';
body: string;
body_draft: string;
created_at: string;
updated_at: string;
word_count: number;
published_at?: string;
first_published_at?: string;
}
// Custom error class for Yuque API errors
export class YuqueApiError extends Error {
constructor(
message: string,
public status?: number,
public code?: string,
public response?: any
) {
super(message);
this.name = 'YuqueApiError';
}
}
export class YuqueClient {
private client: AxiosInstance;
private config: Config;
constructor(configOrToken: Config | string) {
// Support both legacy string token and new config object
if (typeof configOrToken === 'string') {
this.config = {
token: configOrToken,
apiBaseURL: 'https://www.yuque.com/api/v2',
timeout: 30000,
retries: 3,
};
} else {
this.config = configOrToken;
}
this.client = axios.create({
baseURL: this.config.apiBaseURL,
timeout: this.config.timeout,
headers: {
'X-Auth-Token': this.config.token,
'Content-Type': 'application/json',
'User-Agent': 'Yuque-MCP-Server/1.1.0',
},
});
// Add response interceptor for better error handling
this.client.interceptors.response.use(
(response) => response,
(error) => {
const status = error.response?.status;
const data = error.response?.data;
const message = data?.message || error.message || 'Unknown error';
throw new YuqueApiError(
`Yuque API Error (${status}): ${message}`,
status,
data?.code,
data
);
}
);
}
/**
* Make a request to the Yuque API with retry logic
*/
private async request<T>(endpoint: string, options: any = {}): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= this.config.retries; attempt++) {
try {
const response: AxiosResponse = await this.client.request({
url: endpoint,
...options,
});
// Handle different response formats
return response.data.data || response.data;
} catch (error) {
lastError = error as Error;
// Don't retry on client errors (4xx)
if (error instanceof YuqueApiError && error.status && error.status >= 400 && error.status < 500) {
throw error;
}
// Wait before retry (exponential backoff)
if (attempt < this.config.retries) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
}
throw lastError!;
}
/**
* Get current user information
*/
async getUser(): Promise<YuqueUser> {
return this.request<YuqueUser>('/user');
}
/**
* Get repositories (knowledge bases)
*/
async getRepos(userId?: string): Promise<YuqueRepo[]> {
// Use /users/{userId}/repos as confirmed working endpoint
if (!userId) {
const user = await this.getUser();
userId = user.id.toString();
}
return this.request<YuqueRepo[]>(`/users/${userId}/repos`);
}
/**
* Get documents in a repository
*/
async getDocs(repoId: number, options?: { limit?: number; offset?: number }): Promise<YuqueDoc[]> {
const params = new URLSearchParams();
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
const endpoint = `/repos/${repoId}/docs${params.toString() ? '?' + params.toString() : ''}`;
return this.request<YuqueDoc[]>(endpoint);
}
/**
* Get document details
*/
async getDoc(docId: number, repoId: number): Promise<YuqueDoc> {
return this.request<YuqueDoc>(`/repos/${repoId}/docs/${docId}`);
}
/**
* Create a new document
*/
async createDoc(
repoId: number,
title: string,
content: string,
format: 'markdown' | 'lake' | 'html' = 'markdown'
): Promise<YuqueDoc> {
return this.request<YuqueDoc>(`/repos/${repoId}/docs`, {
method: 'POST',
data: {
title,
slug: this.generateSlug(title),
body: content,
format,
},
});
}
/**
* Update an existing document
*/
async updateDoc(
docId: number,
repoId: number,
title?: string,
content?: string,
format?: 'markdown' | 'lake' | 'html'
): Promise<YuqueDoc> {
const data: any = {};
if (title) data.title = title;
if (content) data.body = content;
if (format) data.format = format;
return this.request<YuqueDoc>(`/repos/${repoId}/docs/${docId}`, {
method: 'PUT',
data,
});
}
/**
* Delete a document
*/
async deleteDoc(docId: number, repoId: number): Promise<void> {
await this.request(`/repos/${repoId}/docs/${docId}`, {
method: 'DELETE',
});
}
/**
* Search documents
*/
async searchDocs(query: string, repoId?: number): Promise<YuqueDoc[]> {
const params = new URLSearchParams({
q: query,
type: 'doc', // Required parameter
});
if (repoId) params.append('repo_id', repoId.toString());
return this.request<YuqueDoc[]>(`/search?${params.toString()}`);
}
/**
* Create a new repository (knowledge base)
*/
async createRepo(
name: string,
description?: string,
isPublic: boolean = false
): Promise<YuqueRepo> {
const slug = this.generateSlug(name);
// Get current user ID for repository creation
const user = await this.getUser();
const userId = user.id;
return this.request<YuqueRepo>(`/users/${userId}/repos`, {
method: 'POST',
data: {
name,
slug,
description: description || '',
public: isPublic ? 1 : 0,
type: 'Book',
},
});
}
/**
* Generate a valid slug from repository/document name
*/
private generateSlug(name: string): string {
// Remove Chinese characters and convert to lowercase
let slug = name.toLowerCase()
.replace(/[\u4e00-\u9fa5]/g, '') // Remove Chinese characters
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
.replace(/-+/g, '-') // Merge multiple hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
// If slug is empty after processing, use timestamp
if (!slug) {
slug = 'item-' + Date.now();
}
return slug;
}
}