/** * 代码适配器基类 * * 架构说明: * - Content Script (有 DOM): 负责所有 HTML/DOM 处理 * - 代码块处理 (使用 innerText) * - 懒加载图片处理 * - HTML 转 Markdown * - Service Worker (无 DOM): 只负责 API 调用 * - 接收已处理好的 html 和 markdown * - 图片上传 (URL 替换,不需要 DOM) * - 调用平台 API * * 适配器接收的 Article 对象: * - article.html: 已预处理的 HTML (代码块已简化,图片已处理) * - article.markdown: 已转换的 Markdown * * 适配器只需: * 1. 选择使用 html 还是 markdown * 2. 处理图片上传 (如果平台需要) * 3. 调用平台 API */ import type { Article, AuthResult, SyncResult, PlatformMeta, HeaderRule } from '../types' import type { RuntimeInterface } from '../runtime/interface' import type { PlatformAdapter, PublishOptions } from './types' import { createLogger } from '../lib/logger' import { parseMarkdownImages } from '../lib/markdown-images' const logger = createLogger('CodeAdapter') /** * 图片上传结果 */ export interface ImageUploadResult { /** 新的图片 URL */ url: string /** 额外的 img 属性 */ attrs?: Record } /** * 图片处理选项 */ export interface ImageProcessOptions { /** 跳过匹配这些模式的图片 */ skipPatterns?: string[] /** 进度回调 */ onProgress?: (current: number, total: number) => void } /** * HTML 清理选项 */ export interface CleanHtmlOptions { /** 移除链接标签,保留文字 */ removeLinks?: boolean /** 移除 iframe */ removeIframes?: boolean /** 移除 SVG 图片 */ removeSvgImages?: boolean /** 移除指定标签 */ removeTags?: string[] /** 移除指定属性 */ removeAttrs?: string[] } /** * 代码适配器基类 */ export abstract class CodeAdapter implements PlatformAdapter { abstract readonly meta: PlatformMeta protected runtime!: RuntimeInterface /** Header 规则 ID 列表(用于请求拦截) */ protected headerRuleIds: string[] = [] async init(runtime: RuntimeInterface): Promise { this.runtime = runtime } // ============ 抽象方法,子类必须实现 ============ abstract checkAuth(): Promise abstract publish(article: Article, options?: PublishOptions): Promise // ============ Header 规则管理 ============ /** * 添加 Header 规则 * @param rule 规则配置 * @returns 规则 ID */ protected async addHeaderRule(rule: Omit): Promise { if (!this.runtime.headerRules) return null const ruleId = await this.runtime.headerRules.add(rule) this.headerRuleIds.push(ruleId) return ruleId } /** * 批量添加 Header 规则 * @param rules 规则配置列表 */ protected async addHeaderRules(rules: Array>): Promise { for (const rule of rules) { await this.addHeaderRule(rule) } if (this.headerRuleIds.length > 0) { logger.debug(`[${this.meta.id}] Header rules added:`, this.headerRuleIds) } } /** * 清除所有已添加的 Header 规则 */ protected async clearHeaderRules(): Promise { if (!this.runtime.headerRules || this.headerRuleIds.length === 0) return for (const ruleId of this.headerRuleIds) { await this.runtime.headerRules.remove(ruleId) } logger.debug(`[${this.meta.id}] Header rules cleared:`, this.headerRuleIds) this.headerRuleIds = [] } /** * 在 Header 规则保护下执行操作 * 自动设置规则,执行完成后自动清除 * @param rules 规则配置列表 * @param fn 要执行的操作 */ protected async withHeaderRules( rules: Array>, fn: () => Promise ): Promise { await this.addHeaderRules(rules) try { return await fn() } finally { await this.clearHeaderRules() } } // ============ HTTP 请求能力 ============ /** * GET 请求 */ protected async get(url: string, headers?: Record): Promise { const response = await this.runtime.fetch(url, { method: 'GET', credentials: 'include', headers, }) return this.parseResponse(response) } /** * POST 请求 (JSON) */ protected async postJson( url: string, data: Record, headers?: Record ): Promise { const response = await this.runtime.fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers, }, body: JSON.stringify(data), }) return this.parseResponse(response) } /** * POST 请求 (Form) */ protected async postForm( url: string, data: Record, headers?: Record ): Promise { const response = await this.runtime.fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers, }, body: new URLSearchParams(data), }) return this.parseResponse(response) } /** * POST 请求 (Multipart) */ protected async postMultipart( url: string, formData: FormData, headers?: Record ): Promise { const response = await this.runtime.fetch(url, { method: 'POST', credentials: 'include', headers, body: formData, }) return this.parseResponse(response) } /** * 解析响应 */ private async parseResponse(response: Response): Promise { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const text = await response.text() // 尝试解析 JSON try { return JSON.parse(text) as T } catch { return text as T } } // ============ 图片处理能力 ============ /** * 处理文章图片 (使用正则提取 URL,兼容 Service Worker) * 同时支持 HTML 和 Markdown 格式 * - HTML: text * - Markdown: ![text](url) * * 注意: 这个方法只做 URL 提取和替换,不涉及 DOM 解析 */ protected async processImages( content: string, uploadFn: (src: string) => Promise, options?: ImageProcessOptions ): Promise { const { skipPatterns = [], onProgress } = options || {} // 提取所有图片(HTML + Markdown) const matches: { full: string; src: string; alt?: string; type: 'html' | 'markdown' }[] = [] // 1. HTML 格式: const htmlImgRegex = /]+src="([^"]+)"[^>]*>/gi let match while ((match = htmlImgRegex.exec(content)) !== null) { matches.push({ full: match[0], src: match[1], type: 'html' }) } // 2. Markdown 格式: ![alt](url) for (const mdMatch of parseMarkdownImages(content)) { matches.push({ full: mdMatch.full, src: mdMatch.src, alt: mdMatch.alt, type: 'markdown' }) } if (matches.length === 0) { return content } logger.debug(`Found ${matches.length} images to process (HTML + Markdown)`) let result = content const uploadedMap = new Map() let processed = 0 for (const { full, src, alt, type } of matches) { // 跳过空 src if (!src) continue // 跳过匹配的模式(但不跳过 data URI) if (!src.startsWith('data:')) { const shouldSkip = skipPatterns.some(pattern => src.includes(pattern)) if (shouldSkip) { logger.debug(`Skipping matched pattern: ${src}`) continue } } processed++ onProgress?.(processed, matches.length) try { // 检查是否已上传过 let uploadResult = uploadedMap.get(src) if (!uploadResult) { logger.debug(`Uploading image ${processed}/${matches.length}: ${src.startsWith('data:') ? 'data URI' : src}`) // uploadFn 应该能处理 URL 和 data URI(通过 fetch) uploadResult = await uploadFn(src) uploadedMap.set(src, uploadResult) } // 根据格式构建替换内容 let replacement: string if (type === 'html') { // HTML 格式 replacement = ` { throw new Error('uploadImageByUrl not implemented') } /** * 通过 Blob 上传图片(公开方法,实现 PlatformAdapter 接口) * 默认实现:转为 data URI,调用 uploadImageByUrl * 子类可以覆盖以提供更优的实现 */ async uploadImage(file: Blob, _filename?: string): Promise { const dataUri = await this.blobToDataUri(file) const result = await this.uploadImageByUrl(dataUri) return result.url } /** * Blob 转 data URI */ protected async blobToDataUri(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { const result = reader.result if (typeof result === 'string') { resolve(result) } else { reject(new Error('Failed to read blob as data URI')) } } reader.onerror = () => reject(new Error('FileReader error')) reader.readAsDataURL(blob) }) } /** * data URI 转 Blob */ protected async dataUriToBlob(dataUri: string): Promise { const response = await fetch(dataUri) return response.blob() } // ============ 工具方法 ============ /** * 延迟 */ protected delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } // ============ HTML 处理能力 ============ /** * 清理 HTML 内容 (使用正则,兼容 Service Worker) */ protected cleanHtml(content: string, options: CleanHtmlOptions = {}): string { let result = content // 移除链接标签,保留文字 if (options.removeLinks) { result = result.replace(/]*>([\s\S]*?)<\/a>/gi, '$1') } // 移除 iframe if (options.removeIframes) { result = result.replace(/]*>[\s\S]*?<\/iframe>/gi, '') result = result.replace(/]*\/>/gi, '') } // 移除 SVG 图片 if (options.removeSvgImages) { result = result.replace(/]+src="[^"]*\.svg"[^>]*>/gi, '') } // 移除指定标签 if (options.removeTags) { options.removeTags.forEach(tag => { const regex = new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi') result = result.replace(regex, '') // 自闭合标签 const selfClosing = new RegExp(`<${tag}[^>]*\\/?>`, 'gi') result = result.replace(selfClosing, '') }) } // 移除指定属性 if (options.removeAttrs) { options.removeAttrs.forEach(attr => { if (attr === 'data-*') { result = result.replace(/\s*data-[\w-]+="[^"]*"/gi, '') } else { const regex = new RegExp(`\\s*${attr}="[^"]*"`, 'gi') result = result.replace(regex, '') } }) } return result } /** * 创建同步结果 */ protected createResult(success: boolean, data?: Partial): SyncResult { return { platform: this.meta.id, success, timestamp: Date.now(), ...data, } } }