/** * WordPress XML-RPC 适配器 */ import { createLogger } from '../../lib/logger' import { parseMarkdownImages } from '../../lib/markdown-images' const logger = createLogger('WordPress') interface WordPressCredentials { url: string username: string password: string } interface WordPressPost { post_title: string post_content: string post_status: 'draft' | 'publish' post_type?: string } interface ImageUploadResult { url: string } /** * 构建 XML-RPC 请求体 */ function buildXmlRpcRequest(method: string, params: unknown[]): string { const paramXml = params.map(param => { if (typeof param === 'string') { return `${escapeXml(param)}` } if (typeof param === 'number') { return `${param}` } if (typeof param === 'object' && param !== null) { return `${objectToXmlRpcStruct(param as Record)}` } return `${String(param)}` }).join('') return ` ${method} ${paramXml} ` } function objectToXmlRpcStruct(obj: Record): string { return Object.entries(obj).map(([key, value]) => { let valueXml: string if (typeof value === 'string') { valueXml = `${escapeXml(value)}` } else if (typeof value === 'number') { valueXml = `${value}` } else if (typeof value === 'boolean') { valueXml = `${value ? 1 : 0}` } else if (value instanceof Uint8Array) { // Base64 编码的二进制数据 valueXml = `${arrayBufferToBase64(value)}` } else { valueXml = `${escapeXml(String(value))}` } return `${key}${valueXml}` }).join('') } /** * 将 Uint8Array 转换为 base64 字符串 */ function arrayBufferToBase64(buffer: Uint8Array): string { let binary = '' const len = buffer.byteLength for (let i = 0; i < len; i++) { binary += String.fromCharCode(buffer[i]) } return btoa(binary) } function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * 解析 XML-RPC 响应 */ function parseXmlRpcResponse(xml: string): { success: boolean; value?: unknown; error?: string } { // 检查是否有 fault if (xml.includes('')) { const faultMatch = xml.match(/([^<]+)<\/string>/) return { success: false, error: faultMatch?.[1] || 'XML-RPC 错误' } } // 提取返回值 const valueMatch = xml.match(/[\s\S]*?<(string|int|boolean)>([^<]*)<\/\1>/) if (valueMatch) { return { success: true, value: valueMatch[2] } } // 检查 struct 返回 if (xml.includes('')) { return { success: true, value: {} } } return { success: true } } /** * 测试 WordPress 连接 */ export async function testConnection(credentials: WordPressCredentials): Promise<{ success: boolean; error?: string }> { const xmlrpcUrl = credentials.url.replace(/\/$/, '') + '/xmlrpc.php' try { const body = buildXmlRpcRequest('wp.getUsersBlogs', [ credentials.username, credentials.password, ]) const response = await fetch(xmlrpcUrl, { method: 'POST', headers: { 'Content-Type': 'text/xml', }, body, }) if (!response.ok) { return { success: false, error: `HTTP ${response.status}` } } const xml = await response.text() const result = parseXmlRpcResponse(xml) if (!result.success) { return { success: false, error: result.error } } return { success: true } } catch (error) { return { success: false, error: (error as Error).message } } } /** * 上传图片到 WordPress */ export async function uploadImage( credentials: WordPressCredentials, imageData: Uint8Array, filename: string, mimeType: string ): Promise<{ success: boolean; url?: string; error?: string }> { const xmlrpcUrl = credentials.url.replace(/\/$/, '') + '/xmlrpc.php' try { const fileData = { name: filename, type: mimeType, bits: imageData, overwrite: true, } const body = buildXmlRpcRequest('wp.uploadFile', [ 0, // blog_id credentials.username, credentials.password, fileData, ]) const response = await fetch(xmlrpcUrl, { method: 'POST', headers: { 'Content-Type': 'text/xml', }, body, }) if (!response.ok) { return { success: false, error: `HTTP ${response.status}` } } const xml = await response.text() // 解析上传结果,提取 URL const urlMatch = xml.match(/url<\/name>\s*(?:)?([^<]+)(?:<\/string>)?<\/value>/) if (urlMatch) { return { success: true, url: urlMatch[1] } } // 检查错误 if (xml.includes('')) { const faultMatch = xml.match(/([^<]+)<\/string>/) return { success: false, error: faultMatch?.[1] || 'Upload failed' } } return { success: false, error: '无法解析上传结果' } } catch (error) { return { success: false, error: (error as Error).message } } } const MIME_TO_EXT: Record = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/bmp': 'bmp', 'image/svg+xml': 'svg', } /** * 根据响应 MIME 类型生成文件名,默认 png */ function generateImageFilename(mimeType: string): string { const ext = MIME_TO_EXT[mimeType] || 'png' return `image_${Date.now()}.${ext}` } const MAX_RETRY_ATTEMPTS = 10 const RETRY_DELAY_MS = 1000 // 基础延迟 1 秒 /** * 延迟函数 */ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } /** * 从 URL 下载图片并上传到 WordPress(带重试机制) */ export async function uploadImageByUrl( credentials: WordPressCredentials, imageUrl: string, signal?: AbortSignal ): Promise { let lastError: Error | null = null for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { // 检查是否已取消 if (signal?.aborted) { logger.debug(` Upload aborted for: ${imageUrl}`) return null } try { const result = await doUploadImageByUrl(credentials, imageUrl, signal) if (result) { if (attempt > 1) { logger.debug(` Upload succeeded on attempt ${attempt}: ${imageUrl}`) } return result } // result 为 null 表示失败,继续重试 logger.warn(` Upload attempt ${attempt}/${MAX_RETRY_ATTEMPTS} failed for: ${imageUrl}`) } catch (error) { lastError = error as Error logger.warn(` Upload attempt ${attempt}/${MAX_RETRY_ATTEMPTS} error:`, error) } // 如果不是最后一次尝试,等待后重试 if (attempt < MAX_RETRY_ATTEMPTS) { const delayMs = RETRY_DELAY_MS * attempt // 递增延迟 logger.debug(` Retrying in ${delayMs}ms...`) await delay(delayMs) } } logger.error(` All ${MAX_RETRY_ATTEMPTS} upload attempts failed for: ${imageUrl}`, lastError) return null } /** * 实际执行图片下载和上传 */ async function doUploadImageByUrl( credentials: WordPressCredentials, imageUrl: string, signal?: AbortSignal ): Promise { // 下载图片 (带超时) const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 30000) // 30秒超时 // 如果有外部 signal,监听它 if (signal) { signal.addEventListener('abort', () => controller.abort()) } try { const response = await fetch(imageUrl, { signal: controller.signal }) clearTimeout(timeoutId) if (!response.ok) { logger.error(` Failed to download image (HTTP ${response.status}): ${imageUrl}`) return null } const blob = await response.blob() const arrayBuffer = await blob.arrayBuffer() const imageData = new Uint8Array(arrayBuffer) // 获取 MIME 类型 const mimeType = blob.type || 'image/jpeg' const filename = generateImageFilename(mimeType) logger.debug(` Uploading image: ${filename}, type: ${mimeType}`) // 上传到 WordPress const result = await uploadImage(credentials, imageData, filename, mimeType) if (result.success && result.url) { return { url: result.url } } logger.error(` Failed to upload image: ${result.error}`) return null } catch (error) { clearTimeout(timeoutId) throw error // 向上抛出以触发重试 } } /** * 处理文章中的图片,上传到 WordPress 并替换 URL */ export async function processArticleImages( credentials: WordPressCredentials, content: string, onProgress?: (current: number, total: number) => void, signal?: AbortSignal ): Promise<{ content: string; failedImages: number }> { // 提取所有图片 URL const imgRegex = /]+src="([^"]+)"[^>]*>/gi const matches: { full: string; src: string }[] = [] let match while ((match = imgRegex.exec(content)) !== null) { matches.push({ full: match[0], src: match[1] }) } // 同时处理 Markdown 图片 for (const mdMatch of parseMarkdownImages(content)) { matches.push({ full: mdMatch.full, src: mdMatch.src }) } if (matches.length === 0) { return { content, failedImages: 0 } } logger.debug(` Found ${matches.length} images to process`) let result = content const uploadedMap = new Map() let processed = 0 let failedImages = 0 for (const { full, src } of matches) { // 检查是否已取消 if (signal?.aborted) { throw new Error('操作已取消') } // 跳过空 src 和 data URI if (!src || src.startsWith('data:')) continue // 跳过已经是 WordPress 站点的图片 const wpDomain = new URL(credentials.url).hostname try { const imgDomain = new URL(src).hostname if (imgDomain === wpDomain) { logger.debug(` Skipping same-domain image: ${src}`) continue } } catch { // URL 解析失败,继续处理 } processed++ onProgress?.(processed, matches.length) // 检查是否已上传过 let newUrl = uploadedMap.get(src) if (!newUrl) { logger.debug(` Uploading image ${processed}/${matches.length}: ${src}`) const uploadResult = await uploadImageByUrl(credentials, src, signal) if (uploadResult?.url) { newUrl = uploadResult.url uploadedMap.set(src, newUrl) } else { // 上传失败,跳过该图片,保留原始 URL,继续处理其他图片 failedImages++ logger.warn(`图片上传失败,跳过: ${src.substring(0, 100)}...`) continue } } if (newUrl) { // 替换图片 URL const newTag = full.replace(src, newUrl) result = result.replace(full, newTag) logger.debug(` Image uploaded: ${newUrl}`) } // 避免请求过快 await new Promise(resolve => setTimeout(resolve, 300)) } return { content: result, failedImages } } /** * 发布文章到 WordPress */ export async function publish( credentials: WordPressCredentials, article: { title: string; content: string }, options?: { draftOnly?: boolean; processImages?: boolean; onImageProgress?: (current: number, total: number) => void; signal?: AbortSignal } ): Promise<{ success: boolean; postId?: string; postUrl?: string; message?: string; error?: string }> { const xmlrpcUrl = credentials.url.replace(/\/$/, '') + '/xmlrpc.php' try { // 检查是否已取消 if (options?.signal?.aborted) { return { success: false, error: '操作已取消' } } // 如果启用图片处理,先处理文章中的图片 let content = article.content let failedImages = 0 if (options?.processImages !== false) { logger.debug(' Processing images before publish...') const imageResult = await processArticleImages(credentials, content, options?.onImageProgress, options?.signal) content = imageResult.content failedImages = imageResult.failedImages } // 再次检查是否已取消 if (options?.signal?.aborted) { return { success: false, error: '操作已取消' } } const post: WordPressPost = { post_title: article.title, post_content: content, post_status: options?.draftOnly ? 'draft' : 'publish', post_type: 'post', } const body = buildXmlRpcRequest('wp.newPost', [ 0, // blog_id credentials.username, credentials.password, post, ]) const response = await fetch(xmlrpcUrl, { method: 'POST', headers: { 'Content-Type': 'text/xml', }, body, }) if (!response.ok) { return { success: false, error: `HTTP ${response.status}` } } const xml = await response.text() const result = parseXmlRpcResponse(xml) if (!result.success) { return { success: false, error: result.error } } const postId = String(result.value) const postUrl = options?.draftOnly ? `${credentials.url.replace(/\/$/, '')}/wp-admin/post.php?post=${postId}&action=edit` : `${credentials.url.replace(/\/$/, '')}/?p=${postId}` const message = failedImages > 0 ? `${failedImages} 张图片上传失败` : undefined return { success: true, postId, postUrl, message } } catch (error) { return { success: false, error: (error as Error).message } } }