mirror of
https://github.com/027xiguapi/code-box.git
synced 2026-10-16 03:51:09 +00:00
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
/**
|
||
* 代码适配器基类
|
||
*
|
||
* 架构说明:
|
||
* - 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<string, string | number>
|
||
}
|
||
|
||
/**
|
||
* 图片处理选项
|
||
*/
|
||
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<void> {
|
||
this.runtime = runtime
|
||
}
|
||
|
||
// ============ 抽象方法,子类必须实现 ============
|
||
|
||
abstract checkAuth(): Promise<AuthResult>
|
||
abstract publish(article: Article, options?: PublishOptions): Promise<SyncResult>
|
||
|
||
// ============ Header 规则管理 ============
|
||
|
||
/**
|
||
* 添加 Header 规则
|
||
* @param rule 规则配置
|
||
* @returns 规则 ID
|
||
*/
|
||
protected async addHeaderRule(rule: Omit<HeaderRule, 'id'>): Promise<string | null> {
|
||
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<Omit<HeaderRule, 'id'>>): Promise<void> {
|
||
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<void> {
|
||
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<T>(
|
||
rules: Array<Omit<HeaderRule, 'id'>>,
|
||
fn: () => Promise<T>
|
||
): Promise<T> {
|
||
await this.addHeaderRules(rules)
|
||
try {
|
||
return await fn()
|
||
} finally {
|
||
await this.clearHeaderRules()
|
||
}
|
||
}
|
||
|
||
// ============ HTTP 请求能力 ============
|
||
|
||
/**
|
||
* GET 请求
|
||
*/
|
||
protected async get<T = unknown>(url: string, headers?: Record<string, string>): Promise<T> {
|
||
const response = await this.runtime.fetch(url, {
|
||
method: 'GET',
|
||
credentials: 'include',
|
||
headers,
|
||
})
|
||
return this.parseResponse<T>(response)
|
||
}
|
||
|
||
/**
|
||
* POST 请求 (JSON)
|
||
*/
|
||
protected async postJson<T = unknown>(
|
||
url: string,
|
||
data: Record<string, unknown>,
|
||
headers?: Record<string, string>
|
||
): Promise<T> {
|
||
const response = await this.runtime.fetch(url, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...headers,
|
||
},
|
||
body: JSON.stringify(data),
|
||
})
|
||
return this.parseResponse<T>(response)
|
||
}
|
||
|
||
/**
|
||
* POST 请求 (Form)
|
||
*/
|
||
protected async postForm<T = unknown>(
|
||
url: string,
|
||
data: Record<string, string>,
|
||
headers?: Record<string, string>
|
||
): Promise<T> {
|
||
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<T>(response)
|
||
}
|
||
|
||
/**
|
||
* POST 请求 (Multipart)
|
||
*/
|
||
protected async postMultipart<T = unknown>(
|
||
url: string,
|
||
formData: FormData,
|
||
headers?: Record<string, string>
|
||
): Promise<T> {
|
||
const response = await this.runtime.fetch(url, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers,
|
||
body: formData,
|
||
})
|
||
return this.parseResponse<T>(response)
|
||
}
|
||
|
||
/**
|
||
* 解析响应
|
||
*/
|
||
private async parseResponse<T>(response: Response): Promise<T> {
|
||
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: <img src="url" alt="text">
|
||
* - Markdown: 
|
||
*
|
||
* 注意: 这个方法只做 URL 提取和替换,不涉及 DOM 解析
|
||
*/
|
||
protected async processImages(
|
||
content: string,
|
||
uploadFn: (src: string) => Promise<ImageUploadResult>,
|
||
options?: ImageProcessOptions
|
||
): Promise<string> {
|
||
const { skipPatterns = [], onProgress } = options || {}
|
||
|
||
// 提取所有图片(HTML + Markdown)
|
||
const matches: { full: string; src: string; alt?: string; type: 'html' | 'markdown' }[] = []
|
||
|
||
// 1. HTML 格式: <img ... src="url" ...>
|
||
const htmlImgRegex = /<img[^>]+src="([^"]+)"[^>]*>/gi
|
||
let match
|
||
while ((match = htmlImgRegex.exec(content)) !== null) {
|
||
matches.push({ full: match[0], src: match[1], type: 'html' })
|
||
}
|
||
|
||
// 2. Markdown 格式: 
|
||
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<string, ImageUploadResult>()
|
||
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 = `<img src="${uploadResult.url}"`
|
||
if (uploadResult.attrs) {
|
||
for (const [key, value] of Object.entries(uploadResult.attrs)) {
|
||
replacement += ` ${key}="${value}"`
|
||
}
|
||
}
|
||
replacement += ' />'
|
||
} else {
|
||
// Markdown 格式
|
||
replacement = ``
|
||
}
|
||
|
||
// 替换原内容
|
||
result = result.replace(full, replacement)
|
||
|
||
logger.debug(`Image uploaded: ${uploadResult.url}`)
|
||
} catch (error) {
|
||
logger.error(`Failed to upload image: ${src}`, error)
|
||
// 继续处理其他图片
|
||
}
|
||
|
||
// 避免请求过快
|
||
await this.delay(300)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 上传图片(子类实现)
|
||
* 默认实现抛出错误
|
||
*/
|
||
protected async uploadImageByUrl(_src: string): Promise<ImageUploadResult> {
|
||
throw new Error('uploadImageByUrl not implemented')
|
||
}
|
||
|
||
/**
|
||
* 通过 Blob 上传图片(公开方法,实现 PlatformAdapter 接口)
|
||
* 默认实现:转为 data URI,调用 uploadImageByUrl
|
||
* 子类可以覆盖以提供更优的实现
|
||
*/
|
||
async uploadImage(file: Blob, _filename?: string): Promise<string> {
|
||
const dataUri = await this.blobToDataUri(file)
|
||
const result = await this.uploadImageByUrl(dataUri)
|
||
return result.url
|
||
}
|
||
|
||
/**
|
||
* Blob 转 data URI
|
||
*/
|
||
protected async blobToDataUri(blob: Blob): Promise<string> {
|
||
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<Blob> {
|
||
const response = await fetch(dataUri)
|
||
return response.blob()
|
||
}
|
||
|
||
// ============ 工具方法 ============
|
||
|
||
/**
|
||
* 延迟
|
||
*/
|
||
protected delay(ms: number): Promise<void> {
|
||
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(/<a[^>]*>([\s\S]*?)<\/a>/gi, '$1')
|
||
}
|
||
|
||
// 移除 iframe
|
||
if (options.removeIframes) {
|
||
result = result.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '')
|
||
result = result.replace(/<iframe[^>]*\/>/gi, '')
|
||
}
|
||
|
||
// 移除 SVG 图片
|
||
if (options.removeSvgImages) {
|
||
result = result.replace(/<img[^>]+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>): SyncResult {
|
||
return {
|
||
platform: this.meta.id,
|
||
success,
|
||
timestamp: Date.now(),
|
||
...data,
|
||
}
|
||
}
|
||
}
|