import { BilibiliArticleContentResponse } from '@ikenxuan/amagi' import { createAtNode, createBlockquoteNode, createCodeBlockNode, createEmojiNode, createHeadingNode, createHorizontalRuleNode, createImageNode, createLineBreakNode, createLinkCardNode, createListItemNode, createListNode, createLotteryNode, createOpusLinkNode, createParagraphNode, createRichTextDocument, createTextNode, createTopicNode, createViewPictureNode, createVoteNode, createWebLinkNode, type RichTextDocument, type RichTextInlineStyle, type RichTextNode } from '@kkk/richtext' import { logger, segment, type ElementTypes } from 'node-karin' /** * 用户名元数据,用于传递 VIP 状态和颜色信息 */ export interface UsernameMetadata { name: string vipStatus: number nicknameColor: string | null } /** * 检查用户VIP状态并返回用户名元数据 */ export const getUsernameMetadata = (member: { name?: string; vip?: { status?: number; nickname_color?: string } }): UsernameMetadata => { const vip = member.vip const vipStatus = vip?.status ?? 0 const nicknameColor = vipStatus === 1 && vip?.nickname_color && vip?.nickname_color !== '' ? vip?.nickname_color : null const name = member.name ?? '' return { name, vipStatus, nicknameColor } } /** * 构建用户名的富文本。 * VIP 用户显示彩色名称,普通用户显示灰色名称。 */ export const buildUsernameRichText = (metadata: UsernameMetadata): RichTextDocument => { return createRichTextDocument([createTextNode(metadata.name)], { platform: 'bilibili' }) } /** * 将 B 站动态正文解析成共享富文本 JSON。 * * B站动态的 rich_text_nodes 包含以下类型: * - `topic` / `RICH_TEXT_NODE_TYPE_TOPIC`: 话题 * - `RICH_TEXT_NODE_TYPE_AT`: @提及 * - `RICH_TEXT_NODE_TYPE_LOTTERY`: 抽奖 * - `RICH_TEXT_NODE_TYPE_WEB`: 网页链接 * - `RICH_TEXT_NODE_TYPE_EMOJI`: 表情 * - `RICH_TEXT_NODE_TYPE_VOTE`: 投票 * - `RICH_TEXT_NODE_TYPE_VIEW_PICTURE`: 查看图片 * * 核心逻辑:按 richTextNodes 原始顺序遍历,用节点文本在正文 text 中查找位置, * 来确定普通文本和富文本节点的边界。 */ const URL_REGEX = /https?:\/\/[-\w._~:/?#[\]@!$&'()*+,;=%]+/g /** * 将单行普通文本解析为文本节点和链接节点,自动提取 URL。 */ const parseTextWithUrls = (text: string): Array> => { const nodes: Array> = [] const matches = Array.from(text.matchAll(URL_REGEX)) let lastIndex = 0 for (const match of matches) { if (match.index! > lastIndex) { nodes.push(createTextNode(text.slice(lastIndex, match.index))) } nodes.push(createWebLinkNode(match[0], match[0])) lastIndex = match.index! + match[0].length } if (lastIndex < text.length) { nodes.push(createTextNode(text.slice(lastIndex))) } return nodes } export const buildBilibiliDynamicRichText = ( text: string, richTextNodes: Array<{ type?: string orig_text?: string text?: string emoji?: { gif_url?: string icon_url?: string size?: number } }> ): RichTextDocument => { const nodes: Array< ReturnType< | typeof createTextNode | typeof createEmojiNode | typeof createTopicNode | typeof createAtNode | typeof createLotteryNode | typeof createWebLinkNode | typeof createVoteNode | typeof createViewPictureNode | typeof createLineBreakNode > > = [] if (!richTextNodes || !Array.isArray(richTextNodes) || richTextNodes.length === 0) { if (text) { const parts = text.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { nodes.push(createLineBreakNode()) } else if (part) { nodes.push(...parseTextWithUrls(part)) } } } return createRichTextDocument(nodes, { platform: 'bilibili' }) } // 辅助函数:根据单个 richTextNode 创建对应的富文本节点 const buildNodesFromTag = (tag: (typeof richTextNodes)[number]): Array<(typeof nodes)[number]> => { const matchText = tag.orig_text || tag.text || '' if (!matchText) return [] const result: Array<(typeof nodes)[number]> = [] switch (tag.type) { case 'RICH_TEXT_NODE_TYPE_TEXT': { const parts = matchText.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { result.push(createLineBreakNode()) } else if (part) { result.push(...parseTextWithUrls(part)) } } break } case 'topic': case 'RICH_TEXT_NODE_TYPE_TOPIC': result.push(createTopicNode(matchText)) break case 'RICH_TEXT_NODE_TYPE_AT': result.push(createAtNode(matchText)) break case 'RICH_TEXT_NODE_TYPE_LOTTERY': result.push(createLotteryNode(matchText)) break case 'RICH_TEXT_NODE_TYPE_WEB': result.push(createWebLinkNode(tag.text || matchText, matchText)) break case 'RICH_TEXT_NODE_TYPE_EMOJI': { const emojiUrl = tag.emoji?.gif_url || tag.emoji?.icon_url const scale = tag.emoji?.size === 2 || tag.emoji?.size === 3 ? 2 : undefined if (emojiUrl) { result.push(createEmojiNode(matchText, emojiUrl, { scale })) } else { result.push(createTextNode(matchText)) } break } case 'RICH_TEXT_NODE_TYPE_VOTE': result.push(createVoteNode(tag.text || matchText)) break case 'RICH_TEXT_NODE_TYPE_VIEW_PICTURE': result.push(createViewPictureNode(matchText)) break default: { const parts = matchText.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { result.push(createLineBreakNode()) } else if (part) { result.push(...parseTextWithUrls(part)) } } break } } return result } // text 为空字符串时,直接按 richTextNodes 顺序构建节点,无需在 text 中查找位置 if (!text) { for (const tag of richTextNodes) { nodes.push(...buildNodesFromTag(tag)) } return createRichTextDocument(nodes, { platform: 'bilibili' }) } // 搜索函数:从给定起始位置开始查找子串在 text 中的位置 const findInText = (searchText: string, startPos: number): number => { return text.indexOf(searchText, startPos) } let currentPos = 0 // 当前已处理的 text 位置 for (const tag of richTextNodes) { const matchText = tag.orig_text || tag.text || '' if (!matchText) continue const matchPos = findInText(matchText, currentPos) if (matchPos === -1) continue // 添加匹配位置之前的普通文本 if (matchPos > currentPos) { const beforeText = text.slice(currentPos, matchPos) // 按换行符分割,分别处理普通文本和换行 const parts = beforeText.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { nodes.push(createLineBreakNode()) } else if (part) { nodes.push(...parseTextWithUrls(part)) } } } nodes.push(...buildNodesFromTag(tag)) currentPos = matchPos + matchText.length } // 添加剩余文本 if (currentPos < text.length) { const remainingText = text.slice(currentPos) const parts = remainingText.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { nodes.push(createLineBreakNode()) } else if (part) { nodes.push(...parseTextWithUrls(part)) } } } return createRichTextDocument(nodes, { platform: 'bilibili' }) } /** * 根据 B站视频详情 desc_v2 构建富文本文档。 * * desc_v2 结构: * - type: 1 → 纯文本(含换行、链接) * - type: 2 → @提及(biz_id 为用户ID) * * **入参可能是 `null`/`undefined`**(接口在某些情况下就是这么给的,amagi 自己的类型里 * 也写着 `desc_v2: null`),这里必须容错 —— 直接 `for…of` 会抛 `descV2 is not iterable`。 * 一般不要直接调它,用 {@link buildVideoDescRichText},它会把 `desc` 兜底也算进去。 * * @param descV2 接口给的 desc_v2(可为空) */ export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }> | null | undefined): RichTextDocument => { const nodes: RichTextNode[] = [] if (!descV2) return createRichTextDocument(nodes, { platform: 'bilibili' }) for (const item of descV2) { const rawText = item.raw_text || '' if (!rawText) continue switch (item.type) { case 1: { const parts = rawText.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { nodes.push(createLineBreakNode()) } else if (part) { nodes.push(...parseTextWithUrls(part)) } } break } case 2: nodes.push(createAtNode(`@${rawText}`, item.biz_id?.toString())) break default: nodes.push(createTextNode(rawText)) break } } return createRichTextDocument(nodes, { platform: 'bilibili' }) } /** * 视频详情的简介 → 富文本(**卡片该用这个,不要自己在调用处写三元**)。 * * ## 为什么要有它 * * 各调用点以前是这么写的: * * ```ts * desc: data.desc_v2?.length * ? buildBilibiliVideoDescRichText(data.desc_v2) * : buildBilibiliDynamicRichText(data.desc || '', []) * ``` * * 看着没问题,但 `desc_v2` **不保证是「非空数组」** —— 接口至少有两种给不出内容的形态: * * - **`null`**:amagi 自己的类型里就有一支专职写着 `desc_v2: null`; * - **`[{ raw_text: '', type: 1 }]`**:数组长度是 1,但里面一个字都没有。 * * 前者 `?.length` 是 `undefined`、后者 `?.length` 是 1,判断结果不同, * 但**都不能产生可显示的简介** —— 于是交给模板的都是空文档, * 而卡片模板里写的是 `props.data.desc &&`,空文档为假 ⇒ **整块简介不渲染**, * 而 `desc` 纯文本里明明有内容。用户反馈的「封面卡片没有简介」就是这个。 * * 这个函数把口径收在一处,按**能不能产出内容**判断,而不是按长度: * - `desc_v2` 能产出非空文档 → 用它(带 @ 提及、富文本样式); * - 否则 → 用 `desc` 纯文本兜底(**关键:desc_v2 为空时不能把 desc 也一起丢掉**); * - 两个都没有 → 返回**合法的空文档**(`nodes: []`),模板判假后自然不显示, * 不会抛错、也不会渲染出 `undefined` 字样。 * * @param descV2 接口给的 desc_v2(数组 / null / undefined / 空内容数组都可能) * @param desc 接口给的 desc 纯文本(兜底用) */ export const buildVideoDescRichText = ( descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }> | null | undefined, desc: string | null | undefined ): RichTextDocument => { const fromV2 = buildBilibiliVideoDescRichText(descV2) /** 真的产出了内容才用它:空数组、全空串、null 都会走到 desc 兜底 */ if (fromV2.nodes.length > 0) return fromV2 return buildBilibiliDynamicRichText(desc || '', []) } /** 修复图片 URL 协议。 */ const fixImageUrl = (url: string): string => { if (url.startsWith('//')) { return `https:${url}` } return url } /** 从 opus word.style 提取 RichTextInlineStyle。 */ const extractWordStyle = (style: Record = {}): RichTextInlineStyle => { const result: RichTextInlineStyle = {} if (style.bold) result.bold = true if (style.italic) result.italic = true if (style.strike) result.strike = true if (style.color && typeof style.color === 'string') result.color = style.color if (style.link && typeof style.link === 'string') result.link = style.link return result } /** 解析 opus 段落中的文本节点为 RichTextNode 数组。 */ const parseOpusTextNodes = ( nodes: NonNullable['opus']['content']['paragraphs'][number]['text']>['nodes'], useDarkTheme?: boolean ): RichTextNode[] => { const result: RichTextNode[] = [] for (const node of nodes) { // node_type 4:站内图文高亮链接,官方页面渲染成带图文图标的 if (node.node_type === 4) { const link = node.link if (!link?.show_text) { logger.error(`[bilibili] opus 富文本遇到 node_type=4 但缺少 link 数据: ${JSON.stringify(node).slice(0, 500)}`) continue } // 缺跳转地址时退化成普通文本,至少不丢正文 if (link.link) { result.push(createOpusLinkNode(link.show_text, link.link)) } else { result.push(createTextNode(link.show_text)) } continue } if (node.node_type !== 1 || !node.word) { // 记录未适配的节点类型 if (node.node_type !== undefined && node.node_type !== 1) { logger.error(`[bilibili] opus 富文本遇到未适配的 node_type: ${node.node_type},节点数据: ${JSON.stringify(node).slice(0, 500)}`) } continue } const words = node.word.words || '' if (!words) continue const style = extractWordStyle(node.word.style || {}) // 合并节点自带的颜色(根据主题选择 color 或 dark_color) const colorValue = useDarkTheme ? node.word.dark_color : node.word.color if (colorValue && !style.color) { style.color = colorValue } // 注:B站 font_size 基于宽屏布局,模板固定宽度(w-360 / 实际 1440px 渲染后缩放) // 直接映射会导致正文(17px)在模板中过小。字号统一由前端控制,后端不提取 font_size。 // 按换行符拆分 const parts = words.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { result.push(createLineBreakNode()) } else if (part) { result.push(createTextNode(part, Object.keys(style).length > 0 ? style : undefined)) } } } return result } /** * 将 B 站专栏 opus 结构化数据解析为 RichTextDocument。 */ const parseOpusToRichText = ( opus: NonNullable['opus'], useDarkTheme?: boolean ): RichTextDocument => { const nodes: RichTextNode[] = [] const paragraphs = opus?.content?.paragraphs if (!Array.isArray(paragraphs)) { return createRichTextDocument([], { platform: 'bilibili' }) } let listBuffer: { ordered: boolean items: RichTextNode[] } | null = null const flushList = () => { if (listBuffer) { nodes.push(createListNode(listBuffer.ordered, listBuffer.items as any)) listBuffer = null } } for (const paragraph of paragraphs) { const paraType = paragraph.para_type // 图片段落 if (paraType === 2) { flushList() const pics = paragraph.pic?.pics if (Array.isArray(pics)) { for (const pic of pics) { if (pic.url) { nodes.push(createImageNode(fixImageUrl(pic.url), pic.alt || '专栏图片', pic.comment)) } } } continue } // 分隔线段落 if (paraType === 3) { flushList() nodes.push(createHorizontalRuleNode()) continue } // 链接卡片段落(包含视频卡片等) if (paraType === 7) { flushList() const linkCard = paragraph.link_card const card = linkCard?.card if (card?.link) { nodes.push( createLinkCardNode(card.show_text || linkCard?.default_text || '链接卡片', card.link, { cardType: String(card.link_type || ''), meta: { bizId: card.biz_id, contentCard: card.content_card } }) ) } else { logger.error(`[bilibili] opus 富文本遇到 para_type=7 但缺少 link_card 数据: ${JSON.stringify(paragraph).slice(0, 500)}`) } continue } // 代码块段落 if (paraType === 8) { flushList() const code = paragraph.code if (code?.content) { nodes.push(createCodeBlockNode(code.content, code.lang || undefined)) } else { logger.error(`[bilibili] opus 富文本遇到 para_type=8 但缺少 code 数据: ${JSON.stringify(paragraph).slice(0, 500)}`) } continue } // 文本段落或引用段落或标题段落 const textNodes = paragraph.text?.nodes if (!Array.isArray(textNodes) || textNodes.length === 0) { // 既不是已知块级类型,也没有文本节点——记录未适配的段落类型 const knownTypes = [1, 2, 3, 4, 7, 8, 9] if (!knownTypes.includes(paraType)) { logger.error(`[bilibili] opus 富文本遇到未适配的 para_type: ${paraType},段落数据: ${JSON.stringify(paragraph).slice(0, 500)}`) } continue } // 检查是否是标题段落(通过 format.heading_type、header 样式、或字体大小模拟) const headingType = paragraph.format?.heading_type const hasHeaderStyle = textNodes.some((n) => n.word?.style?.header && n.node_type === 1) // B站有些文章通过 font_size / font_level 模拟标题(para_type 仍为 1) const hasLargeFont = textNodes.some((n) => n.node_type === 1 && n.word?.font_size && n.word.font_size >= 20) const hasXLargeLevel = textNodes.some((n) => n.node_type === 1 && ['xLarge', 'large'].includes(n.word?.font_level)) const isHeading = paraType === 9 || headingType !== undefined || hasHeaderStyle || hasLargeFont || hasXLargeLevel // 根据 font_size 推断标题级别(用于纯字体模拟的标题) const inferredHeadingLevel = (): number => { const sizes = textNodes.filter((n) => n.node_type === 1 && n.word?.font_size).map((n) => n.word.font_size) if (sizes.length === 0) return 2 const maxSize = Math.max(...sizes) if (maxSize >= 26) return 1 if (maxSize >= 22) return 2 if (maxSize >= 20) return 3 return 2 } // 检查段落中是否有 list 样式 const hasList = textNodes.some((n) => n.word?.style?.list && n.node_type === 1) // 检查是否是引用段落 const isBlockquote = paraType === 4 const inlineNodes = parseOpusTextNodes(textNodes, useDarkTheme) if (inlineNodes.length === 0) continue if (isHeading) { flushList() // 优先使用 format.heading_type,其次是 style.header,最后是字体大小推断 let level = inferredHeadingLevel() if (typeof headingType === 'number' && headingType >= 1 && headingType <= 6) { level = headingType } else if (hasHeaderStyle) { const headerNode = textNodes.find((n) => n.word?.style?.header) const headerLevel = headerNode?.word?.style?.header if (typeof headerLevel === 'number' && headerLevel >= 1 && headerLevel <= 6) { level = headerLevel } } nodes.push(createHeadingNode(level as 1 | 2 | 3 | 4 | 5 | 6, inlineNodes)) } else if (isBlockquote) { flushList() nodes.push(createBlockquoteNode(inlineNodes)) } else if (hasList) { // 确定列表类型(bullet / ordered) const listNode = textNodes.find((n) => n.word?.style?.list) const listType = listNode?.word?.style?.list const ordered = listType === 'ordered' if (!listBuffer || listBuffer.ordered !== ordered) { flushList() listBuffer = { ordered, items: [] } } listBuffer.items.push(createListItemNode(inlineNodes)) } else { flushList() nodes.push(createParagraphNode(inlineNodes)) } } flushList() return createRichTextDocument(nodes, { platform: 'bilibili' }) } /** * 尽力解析 HTML content 为 RichTextDocument。 * * 这是一个 "best effort" 解析器,覆盖常见标签: * p, h1-h6, blockquote, ul/ol/li, img, br, b/strong, i/em, s/del, a, span(color)。 */ const parseHtmlContentToRichText = (content: string): RichTextDocument => { const nodes: RichTextNode[] = [] // 块级标签匹配:p, h1-h6, blockquote, ul, ol, li, img, br // 把 content 拆分成块级标记和普通文本 const blockRegex = /<(\/?)(p|h([1-6])|blockquote|ul|ol|li|img|br)(\s[^>]*)?\/?>/gi let lastIndex = 0 let match: RegExpExecArray | null const stack: Array<{ tag: string; level?: 1 | 2 | 3 | 4 | 5 | 6; nodes: RichTextNode[] }> = [] let currentInline: RichTextNode[] = [] const flushInline = (): RichTextNode[] => { const result = currentInline currentInline = [] return result } const pushBlock = (node: RichTextNode) => { if (stack.length > 0) { stack[stack.length - 1].nodes.push(node) } else { nodes.push(node) } } const parseInlineHtml = (html: string): RichTextNode[] => { const result: RichTextNode[] = [] const inlineRegex = /<(\/?)(b|strong|i|em|s|del|a|span|br)(\s[^>]*)?>/gi let inlineLast = 0 let inlineMatch: RegExpExecArray | null const styleStack: RichTextInlineStyle[] = [] const currentStyle = (): RichTextInlineStyle | undefined => { if (styleStack.length === 0) return undefined return styleStack.reduce((acc, s) => ({ ...acc, ...s }), {}) } const pushText = (text: string) => { if (!text) return const style = currentStyle() // 按换行符拆分 const parts = text.split(/(\r?\n)/) for (const part of parts) { if (part === '\r\n' || part === '\n') { result.push(createLineBreakNode()) } else if (part) { result.push(createTextNode(part, style)) } } } while ((inlineMatch = inlineRegex.exec(html)) !== null) { const before = html.slice(inlineLast, inlineMatch.index) pushText(before) const isClose = inlineMatch[1] === '/' const tag = inlineMatch[2].toLowerCase() const attrs = inlineMatch[3] || '' if (tag === 'br') { result.push(createLineBreakNode()) } else if (!isClose) { const style: RichTextInlineStyle = {} if (tag === 'b' || tag === 'strong') style.bold = true if (tag === 'i' || tag === 'em') style.italic = true if (tag === 's' || tag === 'del') style.strike = true if (tag === 'a') { const hrefMatch = /href="([^"]*)"/.exec(attrs) if (hrefMatch) style.link = hrefMatch[1] } if (tag === 'span') { const colorMatch = /color:\s*([^;"]+)/.exec(attrs) if (colorMatch) style.color = colorMatch[1].trim() const styleAttrMatch = /style="([^"]*)"/.exec(attrs) if (styleAttrMatch) { const colorInStyle = /color:\s*([^;]+)/.exec(styleAttrMatch[1]) if (colorInStyle) style.color = colorInStyle[1].trim() } } styleStack.push(style) } else { styleStack.pop() } inlineLast = inlineMatch.index + inlineMatch[0].length } pushText(html.slice(inlineLast)) return result } while ((match = blockRegex.exec(content)) !== null) { // 块级标签之前的文本(如果在根层则忽略;如果在列表/引用内则作为文本) const before = content.slice(lastIndex, match.index) if (before && stack.length > 0) { // 将文本解析为行内节点并入栈顶 const inlineNodes = parseInlineHtml(before) currentInline.push(...inlineNodes) } const isClose = match[1] === '/' const tag = match[2].toLowerCase() const level = match[3] ? parseInt(match[3], 10) : undefined const attrs = match[4] || '' if (!isClose) { if (tag === 'img') { const srcMatch = /src="([^"]*)"/.exec(attrs) if (srcMatch) { const src = fixImageUrl(srcMatch[1]) const altMatch = /alt="([^"]*)"/.exec(attrs) const alt = altMatch ? altMatch[1] : '专栏图片' pushBlock(createImageNode(src, alt)) } } else if (tag === 'br') { currentInline.push(createLineBreakNode()) } else if (tag === 'li') { // 列表项开始:把之前积累的 currentInline 清空 currentInline = [] stack.push({ tag: 'li', nodes: [] }) } else { stack.push({ tag, level: level as 1 | 2 | 3 | 4 | 5 | 6, nodes: [] }) } } else { // 结束标签 if (tag === 'li') { // 结束前还有 currentInline 里的内容 const inlineNodes = flushInline() const itemNodes = stack.pop()?.nodes || [] pushBlock(createListItemNode([...itemNodes, ...inlineNodes])) } else if (tag === 'p' || tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') { const frame = stack.pop() const inlineNodes = flushInline() const childNodes = [...(frame?.nodes || []), ...inlineNodes] if (tag.startsWith('h') && frame?.level) { pushBlock(createHeadingNode(frame.level as 1 | 2 | 3 | 4 | 5 | 6, childNodes)) } else { pushBlock(createParagraphNode(childNodes)) } } else if (tag === 'blockquote') { const frame = stack.pop() const inlineNodes = flushInline() pushBlock(createBlockquoteNode([...(frame?.nodes || []), ...inlineNodes])) } else if (tag === 'ul' || tag === 'ol') { const frame = stack.pop() const items = (frame?.nodes || []).filter((n) => n.type === 'listItem') pushBlock(createListNode(tag === 'ol', items as any)) } } lastIndex = match.index + match[0].length } // 尾部未闭合的普通文本 const tail = content.slice(lastIndex) if (tail) { const inlineNodes = parseInlineHtml(tail) if (inlineNodes.length > 0) { if (stack.length === 0) { nodes.push(createParagraphNode(inlineNodes)) } else { currentInline.push(...inlineNodes) } } } // 处理未闭合的栈(作为段落兜底) while (stack.length > 0) { const frame = stack.pop() if (!frame) continue const inlineNodes = flushInline() const childNodes = [...(frame.nodes || []), ...inlineNodes] if (frame.tag === 'li') { pushBlock(createListItemNode(childNodes)) } else if (frame.tag.startsWith('h') && frame.level) { pushBlock(createHeadingNode(frame.level, childNodes)) } else if (frame.tag === 'blockquote') { pushBlock(createBlockquoteNode(childNodes)) } else { pushBlock(createParagraphNode(childNodes)) } } return createRichTextDocument(nodes, { platform: 'bilibili' }) } /** * 构建 B 站专栏动态的富文本文档。 * * opus 和 content 互斥:优先 opus,其次 content,都没有返回空 document。 */ export const buildBilibiliArticleRichText = ( opus: NonNullable['opus'], content: string | undefined, useDarkTheme?: boolean ): RichTextDocument => { if (opus?.content?.paragraphs) { return parseOpusToRichText(opus, useDarkTheme) } if (content) { return parseHtmlContentToRichText(content) } return createRichTextDocument([], { platform: 'bilibili' }) } type RichTextForwardMessageOptions = { title?: string summary?: string shareUrl?: string imageResolver?: (src: string, index: number) => Promise | string } const MAX_FORWARD_TEXT_LENGTH = 1800 const normalizeForwardText = (text: string): string => { return text .replace(/\r\n/g, '\n') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim() } const splitForwardText = (text: string): string[] => { const chunks: string[] = [] let rest = text while (rest.length > MAX_FORWARD_TEXT_LENGTH) { let splitAt = rest.lastIndexOf('\n\n', MAX_FORWARD_TEXT_LENGTH) if (splitAt < MAX_FORWARD_TEXT_LENGTH / 2) { splitAt = rest.lastIndexOf('\n', MAX_FORWARD_TEXT_LENGTH) } if (splitAt < MAX_FORWARD_TEXT_LENGTH / 2) { splitAt = MAX_FORWARD_TEXT_LENGTH } const chunk = rest.slice(0, splitAt).trim() if (chunk) chunks.push(chunk) rest = rest.slice(splitAt).trim() } if (rest) chunks.push(rest) return chunks } const formatRichTextLink = (text: string, url?: string): string => { if (!url || url === text) return text return `${text} (${url})` } const inlineRichTextNodeToText = (node: RichTextNode): string => { switch (node.type) { case 'text': case 'mention': case 'searchKeyword': case 'topic': case 'at': case 'lottery': case 'vote': case 'viewPicture': case 'hashtag': return node.text case 'emoji': return node.name case 'webLink': return formatRichTextLink(node.text, node.jumpUrl) case 'opusLink': return formatRichTextLink(node.text, node.url) case 'lineBreak': return '\n' default: return '' } } /** * 将共享富文本文档转换为 Karin 消息段。 * * 这里用于专栏正文的合并转发:文本节点保留为 text,图片节点保留为 image, * 让用户在客户端里能按原文顺序查看图文内容,而不是只能看渲染长图。 */ export const buildBilibiliRichTextForwardMessage = async ( document: RichTextDocument, options: RichTextForwardMessageOptions = {} ): Promise => { const elements: ElementTypes[] = [] let textBuffer = '' let imageIndex = 0 const appendText = (text: string) => { if (!text) return textBuffer += text } const ensureBlockBreak = () => { if (!textBuffer) return if (textBuffer.endsWith('\n\n')) return textBuffer += textBuffer.endsWith('\n') ? '\n' : '\n\n' } const flushText = () => { const text = normalizeForwardText(textBuffer) textBuffer = '' for (const chunk of splitForwardText(text)) { elements.push(segment.text(chunk)) } } const appendChildren = async (nodes: RichTextNode[]) => { for (const node of nodes) { await appendNode(node) } } const appendImage = async (src: string, caption?: string) => { flushText() const imageUrl = options.imageResolver ? await options.imageResolver(src, imageIndex) : src imageIndex += 1 elements.push(segment.image(imageUrl)) if (caption) { appendText(caption) ensureBlockBreak() } } const appendNode = async (node: RichTextNode): Promise => { const inlineText = inlineRichTextNodeToText(node) if (inlineText) { appendText(inlineText) return } switch (node.type) { case 'heading': case 'paragraph': case 'blockquote': case 'listItem': ensureBlockBreak() await appendChildren(node.nodes) ensureBlockBreak() break case 'image': if (node.src) { await appendImage(node.src, node.caption) } break case 'list': ensureBlockBreak() for (const [index, item] of node.items.entries()) { appendText(node.ordered ? `${index + 1}. ` : '- ') await appendChildren(item.nodes) appendText('\n') } ensureBlockBreak() break case 'codeBlock': ensureBlockBreak() appendText(node.content) ensureBlockBreak() break case 'linkCard': ensureBlockBreak() appendText(formatRichTextLink(node.title, node.url)) ensureBlockBreak() break case 'horizontalRule': ensureBlockBreak() appendText('---') ensureBlockBreak() break default: break } } const headerLines: string[] = [] if (options.title) headerLines.push(`标题:${options.title}`) if (options.summary) headerLines.push(`简介:${options.summary}`) if (options.shareUrl) headerLines.push(`链接:${options.shareUrl}`) if (headerLines.length > 0) { appendText(headerLines.join('\n')) ensureBlockBreak() } await appendChildren(document.nodes) flushText() return elements }