Files
Mycontent/miniprogram/utils/contentParser.js
2026-04-14 16:00:31 +08:00

559 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 卡若创业派对 - 内容解析工具
* 解析 TipTap HTML 为阅读页可展示的 segments
*
* segment 类型:
* { type: 'text', text }
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap外链→link-preview、小程序→navigateToMiniProgram
* { type: 'image', src, alt } — 图片
* { type: 'video', src } — 正文内嵌视频(管理端 rich-video-wrap / <video>
*/
/** 判断内容是否为 HTML */
function isHtmlContent(content) {
if (!content || typeof content !== 'string') return false
const trimmed = content.trim()
return trimmed.includes('<') && trimmed.includes('>') && /<[a-z][^>]*>/i.test(trimmed)
}
/** 解码常见 HTML 实体 */
function decodeEntities(str) {
return str
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/**
* 正文里 img 的 src管理端常见为 /uploads/...(相对 API 根),浏览器会自动补全域名;
* 小程序 <image> 不会拼接 baseUrl需在此用 assetBase 转成可请求的绝对地址。
* @param {string} src
* @param {string} [assetBase] - 如 https://soulapi.example.com无末尾 /
*/
function resolveArticleImageSrc(src, assetBase) {
if (!src || typeof src !== 'string') return src
const s = src.trim()
if (!s) return s
if (/^(https?:|wxfile:|data:|blob:)/i.test(s)) return s
if (s.startsWith('//')) return 'https:' + s
if (s.startsWith('/')) {
const base = String(assetBase || '').replace(/\/$/, '')
if (!base) return s
return base + s
}
return s
}
/** 从 <img ...> 标签取出 src双引号/单引号) */
function extractImgSrcFromTag(tag) {
const d = tag.match(/src\s*=\s*"([^"]*)"/i)
if (d && d[1] != null) return d[1]
const q = tag.match(/src\s*=\s*'([^']*)'/i)
if (q && q[1] != null) return q[1]
return ''
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
function cleanSingleLineField(s) {
if (!s && s !== 0) return ''
let t = decodeEntities(String(s))
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\r\n|\r|\n/g, ' ')
.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff\u3000]+/g, ' ')
.trim()
return t
}
/** @提及昵称:去首尾空白、零宽、全角空格;合并内部换行/<br> */
function cleanMentionNickname(n) {
return cleanSingleLineField(n)
}
/** 纯文本在 mention 节点前若已有「@」,去掉末尾 @,避免渲染成「找@@阿浪」 */
function stripTrailingAtForMention(before) {
return before.replace(/[@][\s\u00a0\u200b]*$/u, '')
}
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
*/
function parseBlockToSegments(block, config) {
const segs = []
const normalize = s => (s || '').trim().toLowerCase()
const personTokenSet = new Set()
for (const p of ((config && config.persons) || [])) {
const token = normalize((p && p.token) || '')
if (token) personTokenSet.add(token)
}
// 合并匹配所有内联元素
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<img[^>]*\/?>/gi
let lastEnd = 0
let m
while ((m = tokenRe.exec(block)) !== null) {
// 前置纯文本mention 紧挨手写「找@」时去掉重复 @
let before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
before = stripTrailingAtForMention(before)
}
if (before.trim()) segs.push({ type: 'text', text: before })
if (/data-type="mention"/i.test(tag)) {
// @mention — TipTap mention spanspan 内常见「@ 昵称」多空格,统一紧挨显示)
const idMatch = tag.match(/data-id="([^"]*)"/)
const labelMatch = tag.match(/data-label="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '')
const userId = idMatch ? idMatch[1].trim() : ''
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@]\s*/, '')
nickname = cleanMentionNickname((nickname || '').trim())
const userExists = !!normalize(userId) && personTokenSet.has(normalize(userId))
if (userExists && nickname) {
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
} else if (nickname) {
// 被 @ 人物不存在时降级为普通文本,保持“静态 @某人”展示
segs.push({ type: 'text', text: '@' + nickname })
}
} else if (/data-type="linkTag"/i.test(tag)) {
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="..."
const urlMatch = tag.match(/data-url="([^"]*)"/)
const tagTypeMatch = tag.match(/data-tag-type="([^"]*)"/)
const pagePathMatch = tag.match(/data-page-path="([^"]*)"/)
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
const passPhoneMatch = tag.match(/data-pass-phone="([^"]*)"/)
const phoneParamMatch = tag.match(/data-phone-param-name="([^"]*)"/)
const innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, ''))
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
const tagId = tagIdMatch ? tagIdMatch[1] : ''
const appId = appIdMatch ? appIdMatch[1] : ''
const mpKey = mpKeyMatch ? mpKeyMatch[1] : ''
const passPhone = !!(passPhoneMatch && (passPhoneMatch[1] === '1' || /^true$/i.test(passPhoneMatch[1])))
const phoneParamName = (phoneParamMatch && phoneParamMatch[1]) ? phoneParamMatch[1] : 'phone'
segs.push({ type: 'linkTag', label: innerText || '#', url, tagType, pagePath, tagId, appId, mpKey, passPhone, phoneParamName })
} else if (/^<a /i.test(tag)) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)
// m[1] = href, m[2] = innerText以 # 开头)
const url = m[1] || ''
const label = cleanSingleLineField((m[2] || '').replace(/^#/, ''))
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
} else if (/^<img /i.test(tag)) {
// 图片src 可能为相对路径,需结合 config.assetBase
const rawSrc = extractImgSrcFromTag(tag)
const altMatch = tag.match(/alt\s*=\s*"([^"]*)"/i) || tag.match(/alt\s*=\s*'([^']*)'/i)
if (rawSrc) {
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
segs.push({ type: 'image', src, alt: altMatch ? decodeEntities(altMatch[1]) : '' })
}
}
lastEnd = m.index + tag.length
}
// 尾部纯文本
const after = decodeEntities(block.slice(lastEnd).replace(/<[^>]+>/g, ''))
if (after.trim()) segs.push({ type: 'text', text: after })
return segs
}
/**
* 解析 <table> HTML 为结构化数据 { headers: string[], rows: string[][] }
*/
function parseTableHtml(tableHtml) {
const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim())
const headers = []
const rows = []
const headMatch = tableHtml.match(/<thead[^>]*>([\s\S]*?)<\/thead>/i)
if (headMatch) {
const thRe = /<th[^>]*>([\s\S]*?)<\/th>/gi
let m
while ((m = thRe.exec(headMatch[1])) !== null) {
headers.push(stripTags(m[1]))
}
}
const bodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i)
const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
let trM
let isFirst = !headMatch
while ((trM = trRe.exec(bodyContent)) !== null) {
if (headMatch && trM[0] === (headMatch[0].match(/<tr[^>]*>[\s\S]*?<\/tr>/i) || [])[0]) continue
const cells = []
const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
let cM
while ((cM = cellRe.exec(trM[1])) !== null) {
cells.push(stripTags(cM[1]))
}
if (!cells.length) continue
if (isFirst && !headers.length) {
headers.push(...cells)
isFirst = false
} else {
rows.push(cells)
}
}
return { headers, rows }
}
/**
* 从 HTML 中解析出 lines纯文本行和 segments含富文本片段
* segment 新增类型heading / quote / listItem
* @param {string} html
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string }
*/
function parseHtmlToSegments(html, config) {
const lines = []
const segments = []
// 0. 提取 <table> 块
const tables = []
let text = html.replace(/<table[^>]*>[\s\S]*?<\/table>/gi, (match) => {
const idx = tables.length
tables.push(parseTableHtml(match))
return '\n__TABLE_' + idx + '__\n'
})
// 1. 提取 <h2>/<h3> → heading 占位
const headings = []
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
const idx = headings.length
headings.push({ level: parseInt(lvl, 10), raw: inner.trim() })
return '\n__H_' + idx + '__\n'
})
// 2. 提取 <blockquote> → quote 占位
const quotes = []
text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, function (_, inner) {
const idx = quotes.length
quotes.push(inner.trim())
return '\n__Q_' + idx + '__\n'
})
// 2.5 内嵌视频去掉管理端「rich-video-caption」说明仅编辑器用整段 <video> 抽成占位
// 须在列表拆解之前执行,否则 li/段落内 video 会在 parseBlockToSegments 里被 strip 成纯文本
const videos = []
text = text.replace(/<div[^>]*\brich-video-caption\b[^>]*>[\s\S]*?<\/div>/gi, '')
text = text.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, function (match) {
const sm = match.match(/\bsrc\s*=\s*"([^"]*)"/i) || match.match(/\bsrc\s*=\s*'([^']*)'/i)
const rawSrc = sm ? String(sm[1] || '').trim() : ''
if (!rawSrc) return match
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
const idx = videos.length
videos.push({ src })
return '\n__VIDEO_' + idx + '__\n'
})
// 3. 列表 → listItem 占位(保留 ordered 标记)
var olDepth = 0
var olCounter = 0
text = text.replace(/<ol[^>]*>/gi, function () { olDepth++; olCounter = 0; return '\n' })
text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' })
text = text.replace(/<ul[^>]*>/gi, '\n')
text = text.replace(/<\/ul>/gi, '\n')
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
var cleaned = inner.replace(/<p[^>]*>/gi, '').replace(/<\/p>/gi, '').trim()
if (!cleaned) return '\n'
// 列表项内仅有视频占位时,提升为独立视频块(避免 __VIDEO_n__ 当正文展示)
var onlyVid = cleaned.match(/^__VIDEO_(\d+)__$/)
if (onlyVid) {
return '\n__VIDEO_' + onlyVid[1] + '__\n'
}
if (olDepth > 0) {
olCounter++
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
}
return '\n__LI_U__ ' + cleaned + '\n'
})
// 4. 块级标签换行
text = text.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
text = text.replace(/<p[^>]*>/gi, '')
text = text.replace(/<\/p>/gi, '\n')
text = text.replace(/<div[^>]*>/gi, '')
text = text.replace(/<\/div>/gi, '\n')
text = text.replace(/<br\s*\/?>/gi, '\n')
text = text.replace(/<hr\s*\/?>/gi, '\n')
// 5. 逐段解析
var blocks = text.split(/\n+/)
for (var bi = 0; bi < blocks.length; bi++) {
var block = blocks[bi]
if (!block.trim()) continue
// table
var tblM = block.trim().match(/^__TABLE_(\d+)__$/)
if (tblM) {
var tbl = tables[parseInt(tblM[1], 10)]
if (tbl) {
lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n'))
segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }])
}
continue
}
// video
var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
if (vidM) {
var vid = videos[parseInt(vidM[1], 10)]
if (vid && vid.src) {
lines.push('')
segments.push([{ type: 'video', src: vid.src }])
}
continue
}
// heading
var hM = block.trim().match(/^__H_(\d+)__$/)
if (hM) {
var h = headings[parseInt(hM[1], 10)]
if (h) {
var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim())
lines.push(hText)
segments.push([{ type: 'heading', level: h.level, text: hText }])
}
continue
}
// quote
var qM = block.trim().match(/^__Q_(\d+)__$/)
if (qM) {
var qHtml = quotes[parseInt(qM[1], 10)]
if (qHtml) {
var qText = decodeEntities(
qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim()
).replace(/\n{2,}/g, '\n')
lines.push(qText)
segments.push([{ type: 'quote', text: qText }])
}
continue
}
// ordered list item
var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/)
if (liOM) {
var liNum = parseInt(liOM[1], 10)
var liContent = liOM[2].trim()
var liSegs = parseBlockToSegments(liContent, config)
var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim())
lines.push(liNum + '. ' + liText)
segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }])
continue
}
// unordered list item
var liUM = block.match(/^__LI_U__\s*(.*)/)
if (liUM) {
var liContent2 = liUM[1].trim()
var liSegs2 = parseBlockToSegments(liContent2, config)
var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim())
lines.push(liText2)
segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }])
continue
}
// 普通段落
var blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
lines.push('')
segments.push(blockSegs)
continue
}
if (config && (config.persons?.length || config.linkTags?.length)) {
var expanded = []
for (var si = 0; si < blockSegs.length; si++) {
var seg = blockSegs[si]
if (seg.type === 'text' && seg.text) {
expanded.push.apply(expanded, matchLineToSegments(seg.text, config))
} else {
expanded.push(seg)
}
}
blockSegs = expanded
}
var lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
lines.push(lineText)
segments.push(blockSegs)
}
return { lines, segments }
}
/** 清理 Markdown 格式标记(**加粗** *斜体* __加粗__ _斜体_ ~~删除线~~ `代码` 等)*/
function stripMarkdownFormatting(text) {
if (!text) return text
let s = text
s = s.replace(/^#{1,6}\s+/gm, '')
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
s = s.replace(/__(.+?)__/g, '$1')
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1')
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '$1')
s = s.replace(/~~(.+?)~~/g, '$1')
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/^>\s+/gm, '')
s = s.replace(/^---$/gm, '')
s = s.replace(/^\* /gm, '• ')
s = s.replace(/^- /gm, '• ')
s = s.replace(/^\d+\.\s/gm, '')
return s
}
/**
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
* config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
* 点击加好友时须传 persons.token与 CKB lead 的 targetUserId 一致),不能用 personId。
*/
function matchLineToSegments(line, config) {
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
return [{ type: 'text', text: line }]
}
// 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
line = line.replace(/([@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
const normalize = s => (s || '').trim().toLowerCase()
const personMap = {}
const tagMap = {}
for (const p of (config.persons || [])) {
const token = (p.token || '').trim()
if (!token) continue
const display = (p.name || p.label || '').trim()
const aliasStr = p.aliases != null ? String(p.aliases) : ''
const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
.map((x) => (x != null ? String(x) : '').trim())
.filter(Boolean)
.map(normalize)
.filter(Boolean)
for (const k of keys) {
if (!personMap[k]) personMap[k] = p
}
}
for (const t of (config.linkTags || [])) {
const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
for (const k of keys) { if (!tagMap[k]) tagMap[k] = t }
}
const esc = n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const personNames = Object.keys(personMap).sort((a, b) => b.length - a.length).map(esc)
const tagLabels = Object.keys(tagMap).sort((a, b) => b.length - a.length).map(esc)
if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
const parts = []
if (personNames.length) parts.push('[@]\\s*(' + personNames.join('|') + ')')
if (tagLabels.length) parts.push('[#]\\s*(' + tagLabels.join('|') + ')')
const pattern = new RegExp(parts.join('|'), 'gi')
const segs = []
let lastEnd = 0
let m
while ((m = pattern.exec(line)) !== null) {
if (m.index > lastEnd) {
segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
}
const full = m[0]
if (/^[@]/u.test(full)) {
const body = full.replace(/^[@]\s*/u, '')
const person = personMap[normalize(body)]
if (person) {
const nick = cleanSingleLineField(person.name || person.label || body)
const uid = (person.token || '').trim()
if (uid) {
segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
} else {
segs.push({ type: 'text', text: full })
}
} else {
segs.push({ type: 'text', text: full })
}
} else {
const body = full.replace(/^[#]\s*/u, '')
const tag = tagMap[normalize(body)]
if (tag) {
segs.push({
type: 'linkTag',
label: tag.label || body,
url: tag.url || '',
tagType: tag.type || 'url',
pagePath: tag.pagePath || '',
tagId: tag.tagId || '',
appId: tag.appId || '',
mpKey: tag.mpKey || '',
passPhone: !!tag.passPhone,
phoneParamName: tag.phoneParamName || 'phone'
})
} else {
segs.push({ type: 'text', text: full })
}
}
lastEnd = m.index + full.length
}
if (lastEnd < line.length) {
segs.push({ type: 'text', text: line.slice(lastEnd) })
}
return segs.length ? segs : [{ type: 'text', text: line }]
}
/** 纯文本/Markdown 按行解析 */
function parsePlainTextToSegments(text, config) {
const cleaned = stripMarkdownFormatting(text)
const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => matchLineToSegments(line, config))
return { lines, segments }
}
/** 清理残留的 Markdown 图片引用文本(如 "image.png![](xxx)" */
function stripOrphanImageRefs(text) {
if (!text) return text
text = text.replace(/[^\s]*\.(?:png|jpg|jpeg|gif|webp|svg|bmp)!\[[^\]]*\]\([^)]*\)/gi, '')
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
return text
}
/**
* 将原始内容解析为 contentSegments用于阅读页展示
* @param {string} rawContent
* @param {object} [config] - { persons: [], linkTags: [] }
* @returns {{ lines: string[], segments: Array<Array<segment>> }}
*/
function parseContent(rawContent, config) {
if (!rawContent || typeof rawContent !== 'string') {
return { lines: [], segments: [] }
}
let content = stripOrphanImageRefs(rawContent)
if (isHtmlContent(content)) {
return parseHtmlToSegments(content, config)
}
return parsePlainTextToSegments(content, config)
}
module.exports = {
parseContent,
isHtmlContent,
cleanSingleLineField,
}