mirror of
https://github.com/027xiguapi/code-box.git
synced 2026-10-16 12:01:10 +00:00
1149 lines
33 KiB
TypeScript
1149 lines
33 KiB
TypeScript
/**
|
||
* 内容预处理模块 (Content Script 环境,有 DOM)
|
||
*
|
||
* 架构说明:
|
||
* 1. 用户选择 N 个平台
|
||
* 2. 读取每个平台的预处理配置
|
||
* 3. Content Script 为每个平台分别预处理
|
||
* 4. 每个平台收到自己定制的 html/markdown
|
||
* 5. Service Worker 只做图片上传 + 调用 API
|
||
*/
|
||
|
||
import { htmlToMarkdownNative, SOURCE_LINK_REMOVE_DOMAINS, SOURCE_LINK_REDIRECT_RULES } from '../lib/turndown'
|
||
import type { PreprocessConfig } from '../adapters/types'
|
||
import { createLogger } from '../lib/logger'
|
||
|
||
const logger = createLogger('ContentProcessor')
|
||
|
||
// 注意:htmlToMarkdownNative 需要 DOM 环境,只能在 Content Script 中使用
|
||
|
||
// Re-export PreprocessConfig for backward compatibility
|
||
export type { PreprocessConfig }
|
||
|
||
/**
|
||
* 预处理结果
|
||
*/
|
||
export interface PreprocessResult {
|
||
html: string
|
||
markdown: string
|
||
}
|
||
|
||
/**
|
||
* 为单个平台预处理内容
|
||
* @param rawHtml 原始 HTML
|
||
* @param config 平台的预处理配置
|
||
* @returns 处理后的 html 和 markdown
|
||
*
|
||
* 注意:代码块应在入口处用 backupAndSimplifyCodeBlocks 在原始 DOM 上预处理,
|
||
* 此函数中的 processCodeBlocks 会跳过已处理的代码块(有 data-code-simplified 标记)
|
||
*/
|
||
export function preprocessForPlatform(rawHtml: string, config: PreprocessConfig): PreprocessResult {
|
||
// 创建临时 DOM 容器
|
||
const container = document.createElement('div')
|
||
container.innerHTML = rawHtml
|
||
|
||
if (config.processCodeBlocks) {
|
||
processCodeBlocks(container)
|
||
}
|
||
|
||
// 按配置执行预处理
|
||
if (config.removeComments) {
|
||
removeComments(container)
|
||
}
|
||
|
||
if (config.removeIframes) {
|
||
removeElements(container, ['iframe'])
|
||
}
|
||
|
||
if (config.removeSpecialTags) {
|
||
if (config.removeSpecialTagsWithParent) {
|
||
// 知乎等平台:移除特殊标签的父元素
|
||
removeElementsWithParent(container, ['mpprofile', 'qqmusic'])
|
||
// 其他特殊标签只移除自身
|
||
removeElements(container, [
|
||
'mpvoice', 'mpcps', 'mp-miniprogram', 'mp-common-product',
|
||
])
|
||
} else {
|
||
removeElements(container, [
|
||
'mpprofile', 'qqmusic', 'mpvoice', 'mpcps',
|
||
'mp-miniprogram', 'mp-common-product',
|
||
])
|
||
}
|
||
}
|
||
|
||
if (config.removeSvgImages) {
|
||
processSvgImages(container)
|
||
}
|
||
|
||
// 移除 script 和 noscript(总是执行),style 根据配置决定
|
||
removeElements(container, config.keepStyles ? ['script', 'noscript'] : ['script', 'style', 'noscript'])
|
||
|
||
// 清理源平台链接(固定步骤,在 removeLinks 之前执行)
|
||
cleanSourcePlatformLinks(container)
|
||
|
||
if (config.removeLinks) {
|
||
processLinks(container, config.keepLinkDomains)
|
||
}
|
||
|
||
if (config.processLazyImages) {
|
||
processLazyImages(container)
|
||
}
|
||
|
||
if (config.removeEmptyElements) {
|
||
removeEmptyElements(container)
|
||
}
|
||
|
||
if (config.removeEmptyImages) {
|
||
removeEmptyImages(container)
|
||
}
|
||
|
||
if (config.removeDataAttributes) {
|
||
removeDataAttributes(container)
|
||
}
|
||
|
||
if (config.removeSrcset || config.removeSizes) {
|
||
removeImageAttributes(container, config)
|
||
}
|
||
|
||
if (config.convertSectionToDiv) {
|
||
convertSections(container, 'div')
|
||
} else if (config.convertSectionToP) {
|
||
convertSections(container, 'p')
|
||
}
|
||
|
||
// 知乎等平台需要的额外处理
|
||
if (config.removeTrailingBr) {
|
||
removeTrailingBr(container)
|
||
}
|
||
|
||
if (config.unwrapNestedFigures) {
|
||
unwrapNestedFigures(container)
|
||
}
|
||
|
||
if (config.flattenNestedBold) {
|
||
flattenNestedBold(container)
|
||
}
|
||
|
||
if (config.unwrapSingleChildSpans) {
|
||
unwrapSingleChildSpans(container)
|
||
}
|
||
|
||
if (config.unwrapSingleChildContainers) {
|
||
unwrapSingleChildContainers(container)
|
||
}
|
||
|
||
if (config.compactHtml) {
|
||
compactHtml(container)
|
||
}
|
||
|
||
if (config.convertTablesToText) {
|
||
convertTablesToText(container)
|
||
}
|
||
|
||
// 清理空内容
|
||
if (config.removeEmptyLines) {
|
||
removeEmptyLines(container)
|
||
}
|
||
|
||
if (config.removeEmptyDivs) {
|
||
removeEmptyDivs(container)
|
||
}
|
||
|
||
if (config.removeNestedEmptyContainers) {
|
||
removeNestedEmptyContainers(container)
|
||
}
|
||
|
||
// 获取处理后的 HTML
|
||
const html = container.innerHTML
|
||
|
||
// 总是生成 markdown,确保需要 markdown 的适配器能获取到内容
|
||
const markdown = htmlToMarkdownNative(html)
|
||
return { html, markdown }
|
||
}
|
||
|
||
/**
|
||
* 为多个平台预处理内容
|
||
* @param rawHtml 原始 HTML
|
||
* @param configs 各平台的预处理配置 { platformId: config }
|
||
* @returns 各平台的预处理结果 { platformId: { html, markdown } }
|
||
*/
|
||
export function preprocessForMultiplePlatforms(
|
||
rawHtml: string,
|
||
configs: Record<string, PreprocessConfig>
|
||
): Record<string, PreprocessResult> {
|
||
const results: Record<string, PreprocessResult> = {}
|
||
|
||
for (const [platformId, config] of Object.entries(configs)) {
|
||
results[platformId] = preprocessForPlatform(rawHtml, config)
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// ============ 预处理函数 ============
|
||
|
||
/**
|
||
* 移除 HTML 注释
|
||
*/
|
||
function removeComments(container: HTMLElement): void {
|
||
const iterator = document.createNodeIterator(
|
||
container,
|
||
NodeFilter.SHOW_COMMENT,
|
||
null
|
||
)
|
||
const comments: Comment[] = []
|
||
let node: Comment | null
|
||
while ((node = iterator.nextNode() as Comment | null)) {
|
||
comments.push(node)
|
||
}
|
||
comments.forEach((comment) => comment.remove())
|
||
}
|
||
|
||
/**
|
||
* 移除匹配选择器的元素
|
||
*/
|
||
function removeElements(container: HTMLElement, selectors: string[]): void {
|
||
const selector = selectors.join(', ')
|
||
container.querySelectorAll(selector).forEach((el) => el.remove())
|
||
}
|
||
|
||
/**
|
||
* 移除匹配选择器的元素及其父元素
|
||
* 用于处理 mpprofile, qqmusic 等微信特殊标签
|
||
*/
|
||
function removeElementsWithParent(container: HTMLElement, selectors: string[]): void {
|
||
const selector = selectors.join(', ')
|
||
container.querySelectorAll(selector).forEach((el) => {
|
||
// 移除父元素(如果存在且不是 container 本身)
|
||
const parent = el.parentElement
|
||
if (parent && parent !== container) {
|
||
parent.remove()
|
||
} else {
|
||
el.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 处理 SVG 占位图片
|
||
*/
|
||
function processSvgImages(container: HTMLElement): void {
|
||
const svgImages = container.querySelectorAll('img[src^="data:image/svg"]')
|
||
svgImages.forEach((img) => {
|
||
const dataSrc = img.getAttribute('data-src')
|
||
if (dataSrc) {
|
||
img.setAttribute('src', dataSrc)
|
||
} else {
|
||
img.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 清理源平台链接(站内链接去除、跳转中转还原)
|
||
* 规则定义在 @wechatsync/core SOURCE_LINK_*_DOMAINS,新增平台只需加域名
|
||
*/
|
||
function cleanSourcePlatformLinks(container: HTMLElement): void {
|
||
const links = Array.from(container.querySelectorAll('a'))
|
||
for (const link of links) {
|
||
const href = link.getAttribute('href') || ''
|
||
if (SOURCE_LINK_REMOVE_DOMAINS.some(d => href.includes(d))) {
|
||
const parent = link.parentNode
|
||
if (!parent) continue
|
||
while (link.firstChild) {
|
||
parent.insertBefore(link.firstChild, link)
|
||
}
|
||
parent.removeChild(link)
|
||
continue
|
||
}
|
||
const rule = SOURCE_LINK_REDIRECT_RULES.find(r => href.includes(r.domain))
|
||
if (rule) {
|
||
try {
|
||
const url = new URL(href)
|
||
const real = url.searchParams.get(rule.param)
|
||
if (real) link.setAttribute('href', real)
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理链接
|
||
*/
|
||
function processLinks(container: HTMLElement, keepDomains?: string[]): void {
|
||
const links = container.querySelectorAll('a')
|
||
|
||
links.forEach((link) => {
|
||
const href = link.getAttribute('href')
|
||
|
||
if (href && keepDomains?.length) {
|
||
const shouldKeep = keepDomains.some(domain => href.includes(domain))
|
||
if (shouldKeep) return
|
||
}
|
||
|
||
// 用 span 替换 a 标签
|
||
const span = document.createElement('span')
|
||
span.innerHTML = link.innerHTML
|
||
link.parentNode?.replaceChild(span, link)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 处理懒加载图片
|
||
*/
|
||
function processLazyImages(container: HTMLElement): void {
|
||
const imgs = container.querySelectorAll('img')
|
||
const lazySrcAttrs = ['data-src', 'data-original', 'data-actualsrc', '_src']
|
||
|
||
imgs.forEach((img) => {
|
||
for (const attr of lazySrcAttrs) {
|
||
const lazySrc = img.getAttribute(attr)
|
||
if (lazySrc && !lazySrc.startsWith('data:image/svg')) {
|
||
if (!img.src || img.src.startsWith('data:image/svg')) {
|
||
img.src = lazySrc
|
||
}
|
||
break
|
||
}
|
||
}
|
||
lazySrcAttrs.forEach(attr => img.removeAttribute(attr))
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 检测元素是否是行号容器(包含连续数字 1, 2, 3...)
|
||
*/
|
||
function isLineNumberContainer(el: Element, codeLineCount: number): boolean {
|
||
// 检查 ul/ol 的 li 子元素
|
||
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
||
const items = el.querySelectorAll('li')
|
||
// 情况1: li 数量与代码行数匹配(空 li,用 CSS 生成行号)
|
||
if (items.length >= 2 && items.length === codeLineCount) {
|
||
const allEmpty = Array.from(items).every(li => !li.textContent?.trim())
|
||
if (allEmpty) return true
|
||
}
|
||
// 情况2: li 包含连续数字 1, 2, 3...
|
||
if (items.length >= 2) {
|
||
let isSequential = true
|
||
items.forEach((li, i) => {
|
||
const num = parseInt(li.textContent?.trim() || '', 10)
|
||
if (num !== i + 1) isSequential = false
|
||
})
|
||
if (isSequential) return true
|
||
}
|
||
}
|
||
|
||
// 检查元素内容是否是换行分隔的连续数字
|
||
const text = el.textContent?.trim() || ''
|
||
const lines = text.split(/[\n\r]+/).map(s => s.trim()).filter(Boolean)
|
||
if (lines.length >= 2) {
|
||
const allSequential = lines.every((line, i) => parseInt(line, 10) === i + 1)
|
||
if (allSequential) return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 移除 pre 元素相关的行号容器
|
||
* 使用结构检测而非硬编码 class 名
|
||
*/
|
||
function removeLineNumberSiblings(pre: Element): void {
|
||
const parent = pre.parentElement
|
||
if (!parent) return
|
||
|
||
// 计算代码行数(code 元素数量或文本行数)
|
||
const codeElements = pre.querySelectorAll('code')
|
||
const codeLineCount = codeElements.length > 1
|
||
? codeElements.length
|
||
: (pre.textContent?.split('\n').length || 0)
|
||
|
||
// 检查同级兄弟元素
|
||
Array.from(parent.children).forEach(sibling => {
|
||
if (sibling !== pre && isLineNumberContainer(sibling, codeLineCount)) {
|
||
sibling.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 可作为代码行容器的标签
|
||
* 这些标签通常用于包裹单行代码
|
||
*/
|
||
const LINE_CONTAINER_TAGS = new Set(['CODE', 'DIV', 'P', 'LI'])
|
||
|
||
/**
|
||
* 检查子元素是否构成有效的"多行结构"
|
||
*
|
||
* 有效多行结构的特征:
|
||
* 1. 至少2个子元素
|
||
* 2. 全是同一类型的标签(CODE/DIV/SPAN/P/LI)
|
||
* 3. 或者全是 display:block 的元素
|
||
*
|
||
* 无效情况(应使用 innerText):
|
||
* - 混合类型(如 br + span + text)
|
||
* - 少于2个子元素
|
||
* - 子元素是 BR 等非容器标签
|
||
*/
|
||
function isValidLineStructure(children: Element[]): boolean {
|
||
if (children.length < 2) return false
|
||
|
||
const firstTag = children[0].tagName
|
||
|
||
// BR 不是有效的行容器
|
||
if (firstTag === 'BR') return false
|
||
|
||
// 检查是否全是同一类型的行容器标签
|
||
if (LINE_CONTAINER_TAGS.has(firstTag)) {
|
||
const allSameTag = children.every(child => child.tagName === firstTag)
|
||
if (!allSameTag) return false
|
||
|
||
// 如果元素之间存在有意义的文本节点,说明这是内联结构(如 GitHub 语法高亮),
|
||
// 而非每行一个元素的多行结构
|
||
const parent = children[0].parentElement
|
||
if (parent) {
|
||
for (const node of Array.from(parent.childNodes)) {
|
||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// 检查是否全是 display:block 的元素(某些高亮库用自定义标签)
|
||
const allBlock = children.every(child => {
|
||
const style = child.getAttribute('style') || ''
|
||
return style.includes('display:block') || style.includes('display: block')
|
||
})
|
||
|
||
return allBlock
|
||
}
|
||
|
||
/**
|
||
* 递归查找包含代码行的容器
|
||
*
|
||
* 支持的格式:
|
||
* - <pre><code>L1</code><code>L2</code></pre> (微信公众号)
|
||
* - <pre><code><code>L1</code><code>L2</code></code></pre> (嵌套code)
|
||
* - <pre><code><div>L1</div><div>L2</div></code></pre> (expressive-code)
|
||
* - <pre><code><span style="display:block">L1</span>...</code></pre> (某些高亮库)
|
||
*
|
||
* 不匹配的格式(返回 null,使用 innerText):
|
||
* - <pre><code>text<br>text</code></pre> (br换行)
|
||
* - <pre><code>text\ntext</code></pre> (纯文本)
|
||
* - <pre><code>text<span>...</span>text</code></pre> (混合内联)
|
||
*
|
||
* @param el 起始元素
|
||
* @param depth 当前递归深度
|
||
* @returns 包含多行子元素的容器,或 null
|
||
*/
|
||
function findLinesContainer(el: Element, depth: number): Element | null {
|
||
if (depth > 4) return null
|
||
|
||
const children = Array.from(el.children)
|
||
|
||
// 检查当前元素是否是有效的多行容器
|
||
if (isValidLineStructure(children)) {
|
||
return el
|
||
}
|
||
|
||
// 只有一个子元素,继续向下递归
|
||
if (children.length === 1) {
|
||
return findLinesContainer(children[0], depth + 1)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 查找包含代码行的容器(入口函数)
|
||
*/
|
||
function findCodeLinesContainer(pre: Element): Element | null {
|
||
return findLinesContainer(pre, 0)
|
||
}
|
||
|
||
/**
|
||
* 处理代码块
|
||
* 使用 DOM 的 innerText 自动解码 HTML 实体
|
||
* 处理微信等平台将每行代码放在单独标签的情况
|
||
*/
|
||
function processCodeBlocks(container: HTMLElement): void {
|
||
// 1. 先用特定选择器移除已知的行号元素
|
||
removeElements(container, [
|
||
'ul.code-snippet__line-index',
|
||
'.code-snippet__line-index',
|
||
'.line-numbers-rows', // Prism.js
|
||
'.hljs-ln-numbers', // highlight.js
|
||
'.gutter', // 通用
|
||
])
|
||
|
||
// 简化 pre 标签
|
||
const pres = container.querySelectorAll('pre')
|
||
|
||
pres.forEach((pre) => {
|
||
try {
|
||
// 跳过已经被 backupAndSimplifyCodeBlocks 处理过的代码块
|
||
if (pre.hasAttribute('data-code-simplified')) {
|
||
return
|
||
}
|
||
|
||
// 2. 再用结构检测移除未知的行号元素(通用方案)
|
||
removeLineNumberSiblings(pre)
|
||
|
||
// 查找代码行容器
|
||
const linesContainer = findCodeLinesContainer(pre)
|
||
|
||
logger.debug('[processCodeBlocks] pre.innerHTML:', pre.innerHTML.slice(0, 200))
|
||
logger.debug('[processCodeBlocks] linesContainer:', linesContainer?.tagName, 'children:', linesContainer?.children.length)
|
||
|
||
let newHtml: string
|
||
|
||
if (linesContainer) {
|
||
// 多行容器:每个子元素是一行代码
|
||
const lines: string[] = []
|
||
Array.from(linesContainer.children).forEach((child) => {
|
||
const text = child.textContent || ''
|
||
lines.push(escapeHtml(text))
|
||
})
|
||
newHtml = lines.join('\n')
|
||
} else {
|
||
// 普通格式:用 innerText 提取(保留换行)
|
||
// 注意:如果代码块未经 backupAndSimplifyCodeBlocks 预处理,
|
||
// 在 detached DOM 上 innerText 可能无法正确处理 <br> 等
|
||
const text = pre.innerText || pre.textContent || ''
|
||
newHtml = `<code>${escapeHtml(text)}</code>`
|
||
}
|
||
|
||
// 清理:移除开头结尾空行
|
||
newHtml = newHtml
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/\r/g, '\n')
|
||
.replace(/^\n+/, '')
|
||
.replace(/\n+$/, '')
|
||
|
||
// 空代码块跳过
|
||
if (!newHtml.trim()) {
|
||
pre.remove()
|
||
return
|
||
}
|
||
|
||
// 提取语言信息(从 class="language-xxx" 或 data-lang)
|
||
const lang = detectCodeLang(pre)
|
||
|
||
pre.innerHTML = newHtml
|
||
pre.removeAttribute('class')
|
||
pre.removeAttribute('style')
|
||
pre.removeAttribute('data-lang')
|
||
|
||
// 保留语言信息
|
||
if (lang) {
|
||
pre.setAttribute('data-lang', lang)
|
||
pre.className = `language-${lang}`
|
||
}
|
||
} catch (e) {
|
||
logger.error('processCodeBlocks error:', e)
|
||
}
|
||
})
|
||
|
||
}
|
||
|
||
/**
|
||
* 从 pre/code 元素的 class 或 data-lang 中提取语言标识
|
||
* 支持: class="language-js", class="lang-python", class="highlight-typescript", data-lang="go"
|
||
*/
|
||
function detectCodeLang(pre: Element): string | null {
|
||
// 检查 pre 和内部 code 元素
|
||
const elements = [pre, pre.querySelector('code')].filter(Boolean) as Element[]
|
||
|
||
for (const el of elements) {
|
||
// data-lang 属性
|
||
const dataLang = el.getAttribute('data-lang')
|
||
if (dataLang) return dataLang.trim().toLowerCase()
|
||
|
||
// class="language-xxx" / "lang-xxx" / "highlight-xxx"
|
||
const match = el.className.match(/(?:language|lang|highlight)-(\w+)/)
|
||
if (match) return match[1].toLowerCase()
|
||
|
||
// 微信 class="code-snippet__js" 等
|
||
const wxMatch = el.className.match(/code-snippet__(\w+)/)
|
||
if (wxMatch) return wxMatch[1].toLowerCase()
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 仅处理代码块(供 Reader 路径提前调用)
|
||
*/
|
||
export function preprocessCodeBlocks(container: HTMLElement): void {
|
||
processCodeBlocks(container)
|
||
}
|
||
|
||
/**
|
||
* 移除没有有效 src 的 img 标签(src 缺失或为空字符串)
|
||
*/
|
||
function removeEmptyImages(container: HTMLElement): void {
|
||
container.querySelectorAll('img').forEach((img) => {
|
||
if (!img.src || img.src === window.location.href) {
|
||
img.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 移除空元素
|
||
*/
|
||
function removeEmptyElements(container: HTMLElement): void {
|
||
for (let i = 0; i < 3; i++) {
|
||
const emptyElements = container.querySelectorAll('p, div, section, span, figure')
|
||
let removed = 0
|
||
|
||
emptyElements.forEach((el) => {
|
||
const hasText = el.textContent?.trim()
|
||
const hasMedia = el.querySelector('img, video, audio, iframe, canvas, svg')
|
||
|
||
if (!hasText && !hasMedia) {
|
||
el.remove()
|
||
removed++
|
||
}
|
||
})
|
||
|
||
if (removed === 0) break
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 移除 data-* 属性
|
||
*/
|
||
function removeDataAttributes(container: HTMLElement): void {
|
||
const allElements = container.querySelectorAll('*')
|
||
|
||
allElements.forEach((el) => {
|
||
const attrs = Array.from(el.attributes)
|
||
attrs.forEach((attr) => {
|
||
if (attr.name.startsWith('data-') && attr.name !== 'data-src') {
|
||
el.removeAttribute(attr.name)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 移除图片的 srcset/sizes 属性
|
||
*/
|
||
function removeImageAttributes(container: HTMLElement, config: PreprocessConfig): void {
|
||
const images = container.querySelectorAll('img')
|
||
images.forEach((img) => {
|
||
if (config.removeSrcset) img.removeAttribute('srcset')
|
||
if (config.removeSizes) img.removeAttribute('sizes')
|
||
img.removeAttribute('loading')
|
||
img.removeAttribute('decoding')
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 转换 section 标签
|
||
*/
|
||
function convertSections(container: HTMLElement, targetTag: 'div' | 'p'): void {
|
||
const sections = container.querySelectorAll('section')
|
||
sections.forEach((section) => {
|
||
const newEl = document.createElement(targetTag)
|
||
newEl.innerHTML = section.innerHTML
|
||
// 复制属性
|
||
Array.from(section.attributes).forEach((attr) => {
|
||
newEl.setAttribute(attr.name, attr.value)
|
||
})
|
||
section.parentNode?.replaceChild(newEl, section)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 移除段落尾部的 <br> 标签
|
||
*/
|
||
function removeTrailingBr(container: HTMLElement): void {
|
||
// 查找所有 p, div, section 元素
|
||
const elements = container.querySelectorAll('p, div, section')
|
||
elements.forEach((el) => {
|
||
// 移除末尾的 br 标签
|
||
while (el.lastElementChild?.tagName === 'BR') {
|
||
el.lastElementChild.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 解包嵌套的 figure 标签
|
||
* <figure><figure><img></figure></figure> → <figure><img></figure>
|
||
*/
|
||
/**
|
||
* 展平嵌套的 b/strong 标签
|
||
* 移除作为容器使用的内层 b/strong,保留内容(微信编辑器常见问题)
|
||
*/
|
||
function flattenNestedBold(container: HTMLElement): void {
|
||
// b b / b strong / strong b / strong strong —— 任意后代关系都算嵌套
|
||
const selectors = ['b b', 'b strong', 'strong b', 'strong strong']
|
||
for (let i = 0; i < 5; i++) {
|
||
let removed = 0
|
||
for (const sel of selectors) {
|
||
container.querySelectorAll(sel).forEach((inner) => {
|
||
const parent = inner.parentNode
|
||
if (!parent) return
|
||
while (inner.firstChild) {
|
||
parent.insertBefore(inner.firstChild, inner)
|
||
}
|
||
parent.removeChild(inner)
|
||
removed++
|
||
})
|
||
}
|
||
if (removed === 0) break
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解包纯容器 span(减少过深嵌套)
|
||
* 微信编辑器常见:span→span→span→span→text,Toutiao 可能有嵌套深度限制
|
||
* 策略:只要 span 本身没有直接文本内容(内容全在子元素里),就解包,
|
||
* 不限子元素数量和类型。保留含直接文本的 span(混合内容)。
|
||
*/
|
||
function unwrapSingleChildSpans(container: HTMLElement): void {
|
||
for (let i = 0; i < 10; i++) {
|
||
let unwrapped = 0
|
||
|
||
const allSpans = Array.from(container.querySelectorAll('span'))
|
||
|
||
for (const span of allSpans) {
|
||
if (!span.parentNode) continue // already detached
|
||
|
||
// Skip empty spans (no children at all)
|
||
if (span.childNodes.length === 0) continue
|
||
|
||
// If span has any direct non-whitespace text, keep it (mixed content)
|
||
const hasDirectText = Array.from(span.childNodes).some(
|
||
(node) => node.nodeType === Node.TEXT_NODE && !!node.textContent?.trim()
|
||
)
|
||
if (hasDirectText) continue
|
||
|
||
// Pure container span: lift all children up and remove the wrapper
|
||
const parent = span.parentNode
|
||
while (span.firstChild) {
|
||
parent.insertBefore(span.firstChild, span)
|
||
}
|
||
parent.removeChild(span)
|
||
unwrapped++
|
||
}
|
||
|
||
if (unwrapped === 0) break
|
||
}
|
||
}
|
||
|
||
function unwrapNestedFigures(container: HTMLElement): void {
|
||
// 多次迭代处理多层嵌套
|
||
for (let i = 0; i < 5; i++) {
|
||
const nestedFigures = container.querySelectorAll('figure > figure')
|
||
if (nestedFigures.length === 0) break
|
||
|
||
nestedFigures.forEach((innerFigure) => {
|
||
const outerFigure = innerFigure.parentElement
|
||
if (outerFigure?.tagName === 'FIGURE') {
|
||
// 用内层 figure 替换外层
|
||
outerFigure.parentNode?.replaceChild(innerFigure, outerFigure)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解包单一子元素容器
|
||
* 移除只包含单个子元素的无意义包装 div
|
||
*/
|
||
function unwrapSingleChildContainers(container: HTMLElement): void {
|
||
// 多次迭代处理嵌套
|
||
for (let i = 0; i < 5; i++) {
|
||
let unwrapped = 0
|
||
|
||
// 查找只有单个子元素的 div
|
||
const divs = container.querySelectorAll('div')
|
||
divs.forEach((div) => {
|
||
// 检查是否只有单个元素子节点(忽略空白文本节点)
|
||
const children = Array.from(div.childNodes).filter(
|
||
(node) => node.nodeType === Node.ELEMENT_NODE ||
|
||
(node.nodeType === Node.TEXT_NODE && node.textContent?.trim())
|
||
)
|
||
|
||
if (children.length === 1 && children[0].nodeType === Node.ELEMENT_NODE) {
|
||
const child = children[0] as HTMLElement
|
||
// 只解包特定标签
|
||
if (['DIV', 'ARTICLE', 'P', 'SECTION'].includes(child.tagName)) {
|
||
div.parentNode?.replaceChild(child, div)
|
||
unwrapped++
|
||
}
|
||
}
|
||
})
|
||
|
||
if (unwrapped === 0) break
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 压缩 HTML(移除标签间的空白)
|
||
* 适用于 Draft.js 等编辑器
|
||
*/
|
||
function compactHtml(container: HTMLElement): void {
|
||
// 递归处理文本节点,移除标签间的空白
|
||
const walker = document.createTreeWalker(
|
||
container,
|
||
NodeFilter.SHOW_TEXT,
|
||
null
|
||
)
|
||
|
||
const nodesToRemove: Text[] = []
|
||
let node: Text | null
|
||
|
||
while ((node = walker.nextNode() as Text | null)) {
|
||
// 如果文本节点只包含空白,且前后都是元素节点,则标记为移除
|
||
if (node.textContent && /^\s+$/.test(node.textContent)) {
|
||
const prev = node.previousSibling
|
||
const next = node.nextSibling
|
||
const parent = node.parentNode
|
||
|
||
// 在块级元素之间的空白可以移除(跳过 pre/code 及其后代)
|
||
if (parent && !(parent as Element).closest?.('pre, code')) {
|
||
if ((!prev || prev.nodeType === Node.ELEMENT_NODE) &&
|
||
(!next || next.nodeType === Node.ELEMENT_NODE)) {
|
||
nodesToRemove.push(node)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
nodesToRemove.forEach((n) => n.remove())
|
||
}
|
||
|
||
/**
|
||
* 移除空行(只含 br 或空白的段落)
|
||
*/
|
||
function removeEmptyLines(container: HTMLElement): void {
|
||
const elements = container.querySelectorAll('p, section')
|
||
elements.forEach((el) => {
|
||
// 检查是否只包含空白或 br
|
||
const hasOnlyBrOrWhitespace = Array.from(el.childNodes).every((node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
return !node.textContent?.trim()
|
||
}
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
return (node as Element).tagName === 'BR'
|
||
}
|
||
return true
|
||
})
|
||
|
||
if (hasOnlyBrOrWhitespace) {
|
||
el.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 移除空 div(只含 br 或空白,但保留含图片的)
|
||
*/
|
||
function removeEmptyDivs(container: HTMLElement): void {
|
||
const divs = container.querySelectorAll('div')
|
||
divs.forEach((div) => {
|
||
// 如果包含图片/视频等媒体元素,保留
|
||
if (div.querySelector('img, video, audio, canvas, svg, iframe')) {
|
||
return
|
||
}
|
||
|
||
// 检查是否只包含空白或 br
|
||
const hasOnlyBrOrWhitespace = Array.from(div.childNodes).every((node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
return !node.textContent?.trim()
|
||
}
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
return (node as Element).tagName === 'BR'
|
||
}
|
||
return true
|
||
})
|
||
|
||
if (hasOnlyBrOrWhitespace) {
|
||
div.remove()
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 移除嵌套的空容器
|
||
*/
|
||
function removeNestedEmptyContainers(container: HTMLElement): void {
|
||
// 多次迭代处理嵌套
|
||
for (let i = 0; i < 5; i++) {
|
||
let removed = 0
|
||
|
||
const elements = container.querySelectorAll('div, section, article, span')
|
||
elements.forEach((el) => {
|
||
// 如果包含媒体元素,保留
|
||
if (el.querySelector('img, video, audio, canvas, svg, iframe')) {
|
||
return
|
||
}
|
||
|
||
// 检查是否为空或只包含空白
|
||
const text = el.textContent?.trim() || ''
|
||
const hasChildren = el.children.length > 0
|
||
|
||
// 完全空的容器
|
||
if (!text && !hasChildren) {
|
||
el.remove()
|
||
removed++
|
||
return
|
||
}
|
||
|
||
// 只包含 br 的容器
|
||
if (!text && hasChildren) {
|
||
const allBr = Array.from(el.children).every(
|
||
(child) => child.tagName === 'BR'
|
||
)
|
||
if (allBr) {
|
||
el.remove()
|
||
removed++
|
||
}
|
||
}
|
||
})
|
||
|
||
if (removed === 0) break
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将表格转换为文本格式
|
||
* 有表头时: 每行格式化为 "列名: 值 | 列名: 值"
|
||
* 无表头时: 每行用 " | " 分隔各列
|
||
*/
|
||
function convertTablesToText(container: HTMLElement): void {
|
||
const tables = container.querySelectorAll('table')
|
||
|
||
tables.forEach((table) => {
|
||
// 提取表头
|
||
const headers: string[] = []
|
||
const theadRow = table.querySelector('thead tr')
|
||
if (theadRow) {
|
||
theadRow.querySelectorAll('th, td').forEach((cell) => {
|
||
headers.push(cell.textContent?.trim() || '')
|
||
})
|
||
}
|
||
|
||
// 提取所有数据行
|
||
const rows: string[][] = []
|
||
const bodyRows = table.querySelectorAll('tbody tr, tr')
|
||
bodyRows.forEach((row) => {
|
||
// 跳过表头行
|
||
if (row.parentElement?.tagName === 'THEAD') return
|
||
// 如果没有 thead,第一行全是 th 也视为表头
|
||
const cells = row.querySelectorAll('td, th')
|
||
if (headers.length === 0 && row === bodyRows[0]) {
|
||
const allTh = Array.from(cells).every((c) => c.tagName === 'TH')
|
||
if (allTh) {
|
||
cells.forEach((cell) => headers.push(cell.textContent?.trim() || ''))
|
||
return
|
||
}
|
||
}
|
||
|
||
const rowData: string[] = []
|
||
cells.forEach((cell) => {
|
||
rowData.push(cell.textContent?.trim() || '')
|
||
})
|
||
if (rowData.length > 0) {
|
||
rows.push(rowData)
|
||
}
|
||
})
|
||
|
||
// 构建替换内容
|
||
const fragment = document.createDocumentFragment()
|
||
|
||
if (headers.length > 0) {
|
||
// 有表头: "列名: 值 | 列名: 值"
|
||
rows.forEach((row) => {
|
||
const parts = row.map((val, i) => {
|
||
const header = headers[i] || ''
|
||
return header ? `${header}: ${val}` : val
|
||
})
|
||
const p = document.createElement('p')
|
||
p.textContent = parts.join(' | ')
|
||
fragment.appendChild(p)
|
||
})
|
||
} else {
|
||
// 无表头: 直接用 " | " 分隔
|
||
rows.forEach((row) => {
|
||
const p = document.createElement('p')
|
||
p.textContent = row.join(' | ')
|
||
fragment.appendChild(p)
|
||
})
|
||
}
|
||
|
||
table.replaceWith(fragment)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 转义 HTML 特殊字符
|
||
*/
|
||
function escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
// ============ 兼容旧 API ============
|
||
|
||
/**
|
||
* @deprecated 使用 preprocessForPlatform 代替
|
||
*
|
||
* 注意:此函数是全局预处理,不使用平台特定配置。
|
||
* 如需使用平台配置(如知乎的 removeEmptyLines),应使用 preprocessForPlatform。
|
||
*/
|
||
export function preprocessContentDOM(container: HTMLElement): void {
|
||
// 执行默认的全部预处理(不包含平台特定处理)
|
||
removeComments(container)
|
||
removeElements(container, ['iframe', 'script', 'style', 'noscript'])
|
||
removeElements(container, ['mpprofile', 'qqmusic', 'mpvoice', 'mpcps', 'mp-miniprogram', 'mp-common-product'])
|
||
processSvgImages(container)
|
||
processLazyImages(container)
|
||
processCodeBlocks(container)
|
||
removeEmptyElements(container)
|
||
removeDataAttributes(container)
|
||
removeImageAttributes(container, { outputFormat: 'html', removeSrcset: true, removeSizes: true })
|
||
}
|
||
|
||
/**
|
||
* @deprecated 使用 preprocessForPlatform 代替
|
||
*/
|
||
export function preprocessContentString(html: string): string {
|
||
const tempDiv = document.createElement('div')
|
||
tempDiv.innerHTML = html
|
||
preprocessContentDOM(tempDiv)
|
||
return tempDiv.innerHTML
|
||
}
|
||
|
||
// ============ 代码块 Backup/Restore (用于原始 DOM 处理) ============
|
||
|
||
/**
|
||
* 元素备份信息
|
||
*/
|
||
export interface ElementBackup {
|
||
element: Element
|
||
originalHTML: string
|
||
}
|
||
|
||
/**
|
||
* 在原始 DOM 上简化代码块,返回备份以便恢复
|
||
* 必须在克隆之前调用,因为 innerText 只在真实 DOM 上正确工作
|
||
*
|
||
* 使用与 processCodeBlocks 相同的逻辑,确保一致性
|
||
*
|
||
* @param root 要处理的根元素(默认为 document.body)
|
||
*/
|
||
export function backupAndSimplifyCodeBlocks(root: Element = document.body): ElementBackup[] {
|
||
const backups: ElementBackup[] = []
|
||
|
||
// 行号元素选择器(用于临时隐藏)
|
||
const GUTTER_SELECTORS = [
|
||
'.gutter',
|
||
'.line-numbers-rows',
|
||
'.hljs-ln-numbers',
|
||
'.code-snippet__line-index',
|
||
'ul.code-snippet__line-index',
|
||
'[class*="line-number"]',
|
||
'[class*="lineNumber"]',
|
||
].join(', ')
|
||
|
||
root.querySelectorAll('pre').forEach((pre) => {
|
||
try {
|
||
// 保存原始 HTML
|
||
const originalHTML = pre.innerHTML
|
||
|
||
// 临时隐藏行号元素(不删除,因为要恢复)
|
||
const gutterEls = pre.querySelectorAll(GUTTER_SELECTORS)
|
||
const gutterDisplays: string[] = []
|
||
gutterEls.forEach((el, i) => {
|
||
gutterDisplays[i] = (el as HTMLElement).style.display
|
||
;(el as HTMLElement).style.display = 'none'
|
||
})
|
||
|
||
// 使用结构检测移除未知的行号元素(临时)
|
||
// 注意:这里不能调用 removeLineNumberSiblings 因为会修改 DOM
|
||
// 我们只是临时隐藏,所以跳过这步
|
||
|
||
// 查找代码行容器(与 processCodeBlocks 相同的逻辑)
|
||
const linesContainer = findCodeLinesContainer(pre)
|
||
|
||
let cleanedText: string
|
||
|
||
if (linesContainer) {
|
||
// 多行容器:每个子元素是一行代码
|
||
const lines: string[] = []
|
||
Array.from(linesContainer.children).forEach((child) => {
|
||
const text = child.textContent || ''
|
||
lines.push(text)
|
||
})
|
||
cleanedText = lines.join('\n')
|
||
} else {
|
||
// 普通格式:用 innerText 提取(在真实 DOM 上能正确处理 br 等)
|
||
const code = pre.querySelector('code')
|
||
const targetEl = (code || pre) as HTMLElement
|
||
cleanedText = targetEl.innerText || ''
|
||
}
|
||
|
||
// 恢复行号显示
|
||
gutterEls.forEach((el, i) => {
|
||
;(el as HTMLElement).style.display = gutterDisplays[i]
|
||
})
|
||
|
||
// 清理首尾空白
|
||
cleanedText = cleanedText
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/\r/g, '\n')
|
||
.replace(/^\n+/, '')
|
||
.replace(/\n+$/, '')
|
||
|
||
// 跳过空代码块
|
||
if (!cleanedText.trim()) return
|
||
|
||
logger.debug('[backupAndSimplifyCodeBlocks] original:', originalHTML.slice(0, 100))
|
||
logger.debug('[backupAndSimplifyCodeBlocks] cleaned text:', cleanedText.slice(0, 100))
|
||
|
||
// 在替换 innerHTML 前检测语言(之后 data-lang/class 会丢失)
|
||
const lang = detectCodeLang(pre)
|
||
|
||
backups.push({
|
||
element: pre,
|
||
originalHTML: originalHTML,
|
||
})
|
||
|
||
// 替换为纯文本,添加标记表示已处理
|
||
pre.innerHTML = `<code>${escapeHtml(cleanedText)}</code>`
|
||
pre.setAttribute('data-code-simplified', 'true')
|
||
|
||
// 保留语言信息到标准格式
|
||
if (lang) {
|
||
pre.setAttribute('data-lang', lang)
|
||
pre.className = `language-${lang}`
|
||
}
|
||
} catch (e) {
|
||
logger.error('[backupAndSimplifyCodeBlocks] error:', e)
|
||
}
|
||
})
|
||
|
||
return backups
|
||
}
|
||
|
||
/**
|
||
* 恢复被简化的代码块
|
||
*/
|
||
export function restoreCodeBlocks(backups: ElementBackup[]): void {
|
||
backups.forEach(({ element, originalHTML }) => {
|
||
element.innerHTML = originalHTML
|
||
})
|
||
}
|