// scripts/parse-qwen-tts-dom.js — 从 playwright 渲染的 DOM 文本提取 Qwen-TTS 音色列表 const fs = require('fs'); const os = require('os'); const path = require('path'); const input = process.argv[2] || path.join(os.tmpdir(), 'qwen-tts-dom.txt'); const raw = fs.readFileSync(input, 'utf8'); // 用一个 normalizer 把所有空白都换成换行,合并空行 const normalized = raw .replace(/[ \t ]+/g, ' ') // 折叠空白(空格/tab/全角空格) .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n{2,}/g, '\n'); const lines = normalized.split('\n').map(l => l.trim()).filter(Boolean); // 找所有"音色名:X"行 const voices = []; for (let i = 0; i < lines.length; i++) { if (!/^音色名[::]\s*(.+)$/.test(lines[i])) continue; // 已经处理过的(第二个表格里又出现 Cherry),跳过 const m = lines[i].match(/^音色名[::]\s*(.+)$/); if (!m) continue; const name = m[1].trim(); // 下一行非空就是描述 let desc = ''; let gender = ''; for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) { const dm = lines[j].match(/^描述[::]\s*(.+)$/); if (!dm) continue; const full = dm[1].trim(); const g = full.match(/[((]([男女])性[))]\s*$/); if (g) gender = g[1] === '女' ? '女' : '男'; desc = full.replace(/[((][男女]性[))]\s*$/, '').trim(); break; } if (desc) voices.push({ name, desc, gender }); } // 去重(同样的音色名只保留第一个出现,忽略表格末尾重复的"芊悦 Cherry"那段) const seen = new Set(); const unique = voices.filter(v => { if (seen.has(v.name)) return false; seen.add(v.name); return true; }); console.error(`解析出 ${unique.length} 个音色(去重前 ${voices.length})`); // 输出 markdown 表格 const out = [`## Qwen-TTS 音色清单(完整,官方页面抓取)\n`]; out.push(`> 数据来源:[阿里云 Qwen-TTS 音色列表](https://help.aliyun.com/zh/model-studio/qwen-tts-voice-list),2026-07 用 playwright 渲染抓取。共 ${unique.length} 个音色。`); out.push(''); out.push('| 音色名 | 描述 | 性别 |'); out.push('|--------|------|------|'); for (const v of unique) { const d = v.desc.replace(/\|/g, '\\|'); out.push(`| \`${v.name}\` | ${d} | ${v.gender} |`); } // 外语音色分类 const foreign = unique.filter(v => /(英语|美语|法|德|意|俄|韩|西班牙|葡萄牙|拉美|日语|阿拉伯)/.test(v.desc)); if (foreign.length) { out.push(''); out.push('### 外语音色(按语言)'); out.push(''); out.push('| 音色名 | 描述 | 性别 |'); out.push('|--------|------|------|'); for (const v of foreign) { out.push(`| \`${v.name}\` | ${v.desc.replace(/\|/g, '\\|')} | ${v.gender} |`); } } // 方言/地区音色分类 const dialect = unique.filter(v => /(北京|上海|南京|陕西|闽南|天津|四川|粤语|胡同|沪上)/.test(v.name + v.desc)); if (dialect.length) { out.push(''); out.push('### 中文方言/地区音色'); out.push(''); out.push('| 音色名 | 描述 | 性别 |'); out.push('|--------|------|------|'); for (const v of dialect) { out.push(`| \`${v.name}\` | ${v.desc.replace(/\|/g, '\\|')} | ${v.gender} |`); } } // 角色/特殊 const character = unique.filter(v => /(萝莉|小大人|少年|大叔|小哥|小新|老李|瑜伽|阿姐|哥仔|老者|设计师|少年)/.test(v.desc)); if (character.length) { out.push(''); out.push('### 角色/特色音色'); out.push(''); out.push('| 音色名 | 描述 | 性别 |'); out.push('|--------|------|------|'); for (const v of character) { out.push(`| \`${v.name}\` | ${v.desc.replace(/\|/g, '\\|')} | ${v.gender} |`); } } out.push(''); out.push('### 选型速查'); out.push(''); out.push('| 场景 | 推荐音色 |'); out.push('|------|---------|'); out.push('| **有声书/广播剧(温柔女)** | 芊悦(Cherry,默认)、苏瑶、四月 |'); out.push('| **有声书(磁性男)** | 凯、晨煦(带北方口音)、安德雷 |'); out.push('| **新闻播报** | 阿闻(最专业主持人)、晨煦 |'); out.push('| **儿童/童趣** | 萌宝(小萝莉)、十三(拽拽小暴躁)、顽屁小孩(调皮捣蛋)、沙小弥(小大人) |'); out.push('| **品牌级朗读** | 詹妮弗(电影质感美语女声)、御姐系卡捷琳娜 |'); out.push('| **戏感配音** | 甜茶(节奏张力) |'); out.push('| **老者/沉稳** | 沧明子(沧桑睿智)、徐大爷(沙哑烟嗓) |'); out.push('| **方言版本** | 上海-阿珍、北京-晓东、南京-老李、陕西-秦川、闽南-阿杰、天津-李彼得、四川-晴儿/程川、粤语-阿强/阿清 |'); out.push('| **欧美外语** | 詹妮弗/艾登(美)、莱恩(德)、埃米尔安(法)、博德加(西班牙,热情大叔)、拉迪奥·戈尔(葡,足球解说) |'); out.push('| **东亚/俄** | 素熙(韩)、小野杏(日)、阿列克(俄,战斗民族冷) |'); console.log(out.join('\n')); fs.writeFileSync(path.join(os.tmpdir(), 'qwen-tts-voices.json'), JSON.stringify(unique, null, 2), 'utf8'); fs.writeFileSync(path.join(os.tmpdir(), 'qwen-tts-section.md'), out.join('\n'), 'utf8');