diff --git a/.dsh-plugin/client.js b/.dsh-plugin/client.js index 2577fc1..fe07350 100644 --- a/.dsh-plugin/client.js +++ b/.dsh-plugin/client.js @@ -2355,9 +2355,13 @@ function advance(state, dtMs, speed = SPEEDS.normal) { } // .dsh-plugin/client/transcript.mjs +function stripMarkdown(text) { + if (typeof text !== "string") return ""; + return text.replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, "$1").replace(/`([^`\n]+)`/g, "$1").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1").replace(/<([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]+)>/g, "$1").replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "").replace(/^[ \t]{0,3}>[ \t]?/gm, "").replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, "").replace(/^[ \t]{0,3}[-*+][ \t]+/gm, "").replace(/^[ \t]{0,3}([-*_])\1{2,}[ \t]*$/gm, "").replace(/~~([^\n~]+)~~/g, "$1").replace(/\*\*([^\n*]+)\*\*/g, "$1").replace(/__([^\n_]+)__/g, "$1").replace(/(?)。 + * + * 边界规则:斜体 `*x*`/`_x_` 要求起始符不紧跟空白、结束符不紧贴空白, + * 且两侧均不在字母数字内,避免误伤 `2 * 3`、`a*b`、`snake_case`;粗体 `**x**`/`__x__` + * 因双符成对无歧义,先用宽松规则剥离再处理斜体。未闭合的标记保持原样(安全)。 + * + * @param {string} text - 原始文本(建议先按 \n 归一换行)。 + * @returns {string} 去除 Markdown 标记后的纯文本。 */ -export function cleanDialogueText(text) { +export function stripMarkdown(text) { if (typeof text !== 'string') return '' return text - .replace(/\r\n?/g, '\n') + // 围栏代码块:```lang\n...\n``` → 保留正文(去围栏与语言标识)。 + .replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, '$1') + // 行内代码:`code` → code + .replace(/`([^`\n]+)`/g, '$1') + // 图片:![alt](url) → alt + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + // 链接:[text](url) → text + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + // 参考式链接:[text][ref] → text + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + // 自动链接: → scheme(保留协议头,丢弃尖括号) + .replace(/<([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]+)>/g, '$1') + // 标题行首标记:# ## ### ... + .replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '') + // 引用行首标记:> > + .replace(/^[ \t]{0,3}>[ \t]?/gm, '') + // 有序列表行首标记:1. / 1) + .replace(/^[ \t]{0,3}\d+[.)][ \t]+/gm, '') + // 无序列表行首标记:- * + + .replace(/^[ \t]{0,3}[-*+][ \t]+/gm, '') + // 水平分隔线(独占一行:--- *** ___) + .replace(/^[ \t]{0,3}([-*_])\1{2,}[ \t]*$/gm, '') + // 删除线:~~text~~ → text + .replace(/~~([^\n~]+)~~/g, '$1') + // 粗体:**text** / __text__ → text(双符成对,先用宽松规则) + .replace(/\*\*([^\n*]+)\*\*/g, '$1') + .replace(/__([^\n_]+)__/g, '$1') + // 斜体 *text* → text:起始符不跟空白、结束符不贴空白,两侧不在字母数字中。 + .replace(/(? { assert.equal(assistantSpeaker(named).name, '雾子') }) +test('stripMarkdown 剥离行内强调标记', () => { + assert.equal(stripMarkdown('*斜体*'), '斜体') + assert.equal(stripMarkdown('**粗体**'), '粗体') + assert.equal(stripMarkdown('~~删除线~~'), '删除线') + assert.equal(stripMarkdown('`代码`'), '代码') + assert.equal(stripMarkdown('_斜体_'), '斜体') + assert.equal(stripMarkdown('__粗体__'), '粗体') + // 混排:粗体 + 斜体共存 + assert.equal(stripMarkdown('**粗体** 和 *斜体*'), '粗体 和 斜体') + // 未闭合标记保持原样(安全) + assert.equal(stripMarkdown('*未闭合'), '*未闭合') + assert.equal(stripMarkdown('**未闭合'), '**未闭合') +}) + +test('stripMarkdown 不误伤非强调用法', () => { + // 数学乘法(带空格)不剥离 + assert.equal(stripMarkdown('2 * 3 = 6'), '2 * 3 = 6') + assert.equal(stripMarkdown('a * b * c'), 'a * b * c') + // 词内星号不剥离 + assert.equal(stripMarkdown('a*b*c'), 'a*b*c') + // snake_case 不剥离 + assert.equal(stripMarkdown('snake_case_var'), 'snake_case_var') + assert.equal(stripMarkdown('foo_bar 与 baz_qux'), 'foo_bar 与 baz_qux') + // 行首单独 * 作为列表项才剥离 + assert.equal(stripMarkdown('* 列表项'), '列表项') +}) + +test('stripMarkdown 剥离行首结构与块级标记', () => { + assert.equal(stripMarkdown('# 标题'), '标题') + assert.equal(stripMarkdown('## 二级标题'), '二级标题') + assert.equal(stripMarkdown('> 引用'), '引用') + assert.equal(stripMarkdown('- 无序项'), '无序项') + assert.equal(stripMarkdown('+ 加号项'), '加号项') + assert.equal(stripMarkdown('1. 有序项'), '有序项') + assert.equal(stripMarkdown('2) 有序项'), '有序项') + // 多行列表:逐行剥离行首标记 + assert.equal(stripMarkdown('* 一\n* 二\n* 三'), '一\n二\n三') +}) + +test('stripMarkdown 剥离链接与图片', () => { + assert.equal(stripMarkdown('[链接文本](https://example.com)'), '链接文本') + assert.equal(stripMarkdown('![替代文本](https://example.com/a.png)'), '替代文本') + assert.equal(stripMarkdown('前文 [链接](url) 后文'), '前文 链接 后文') + assert.equal(stripMarkdown('[参考][ref1]'), '参考') + assert.equal(stripMarkdown(''), 'https://example.com') +}) + +test('stripMarkdown 剥离代码块围栏', () => { + assert.equal(stripMarkdown('```js\nconsole.log(1)\n```'), 'console.log(1)') + assert.equal(stripMarkdown('```\n纯文本代码\n```'), '纯文本代码') +}) + +test('stripMarkdown 剥离水平分隔线', () => { + assert.equal(stripMarkdown('上文\n---\n下文'), '上文\n\n下文') + assert.equal(stripMarkdown('上文\n***\n下文'), '上文\n\n下文') +}) + +test('cleanDialogueText 剥离 Markdown 标记后正常清洗', () => { + assert.equal(cleanDialogueText('**你好**,世界'), '你好,世界') + assert.equal(cleanDialogueText('这是 *斜体* 内容'), '这是 斜体 内容') // 行中斜体剥离 + assert.equal(cleanDialogueText('# 标题\n正文'), '标题\n正文') + assert.equal(cleanDialogueText('- 项一\n- 项二'), '项一\n项二') + assert.equal(cleanDialogueText('[link](url) 文本'), 'link 文本') + assert.equal(cleanDialogueText(null), '') +}) + test('cleanDialogueText 折叠成串空行并去首尾空行', () => { assert.equal(cleanDialogueText('a\n\n\n\n\nb'), 'a\n\nb') assert.equal(cleanDialogueText('a\n\n\n\n\n\n\n\n\n\nb'), 'a\n\nb')