/**
* 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 }
}
}