// ==UserScript== // @name Radio Garden 中文汉化 // @name:zh-CN Radio Garden 中文汉化 // @namespace https://radio.garden/ // @version 1.1.0 // @description 将 radio.garden 的界面文字翻译为中文(只翻 UI 词条,不翻电台名/城市名/国家名) // @author you // @match https://radio.garden/* // @match http://radio.garden/* // @icon https://radio.garden/favicon.ico // @run-at document-start // @grant none // @tag 汉化 中文 翻译 // ==/UserScript== (function () { 'use strict'; // ===== 词典:英文 -> 中文 ===== // 只放界面上固定出现的 UI 词条。不要放电台名/城市名/国家名,避免影响搜索与对应关系。 const DICT = { // 主菜单 / 导航 'Explore': '探索', 'Search': '搜索', 'Favorites': '收藏', 'Favourites': '收藏', 'My Favorites': '我的收藏', 'My Favourites': '我的收藏', 'History': '历史', 'Recent': '最近收听', 'Settings': '设置', 'About': '关于', 'Info': '信息', 'Menu': '菜单', 'Home': '首页', 'Back': '返回', 'Close': '关闭', 'Share': '分享', // 提示 / 说明 'Loading…': '加载中…', 'Loading...': '加载中…', 'Loading': '加载中', 'Now playing': '正在播放', 'No results': '没有结果', 'No favorites yet': '还没有收藏', 'No favourites yet': '还没有收藏', 'No history yet': '还没有收听记录', 'Search for a city or radio': '搜索城市或电台', 'Search for cities or stations': '搜索城市或电台', 'Country, City, Station': '国家、城市、电台', 'Drag to explore': '拖动地球以探索', 'Drag the globe to explore': '拖动地球进行探索', // 播放器 / 操作 'Play': '播放', 'Pause': '暂停', 'Stop': '停止', 'Mute': '静音', 'Unmute': '取消静音', 'Volume': '音量', 'Add to favorites': '加入收藏', 'Add to favourites': '加入收藏', 'Remove from favorites': '取消收藏', 'Remove from favourites': '取消收藏', 'Previous': '上一个', 'Next': '下一个', // 列表 / 区块标题(固定文案) 'Picks from the Area': '本地区精选', 'View more popular stations': '查看更多热门电台', 'View all stations': '查看全部电台', 'Browse': '浏览', 'Loading stream': '正在加载音频流', 'All Stations': '全部电台', 'Popular Stations': '热门电台', 'Popular Across the Globe': '全球热门', 'Recently added radio stations': '最近新增的电台', 'Recent Searches': '最近搜索', 'New to the Garden': '新加入花园', 'New': '新', 'More Playlists': '更多播放列表', 'View More': '查看更多', 'View more new stations': '查看更多新电台', 'View playlist': '查看播放列表', 'An audience favorite for every country': '每个国家的听众最爱', // 播放器 / 电台操作 'no station selected': '未选择电台', 'paused': '已暂停', 'Lock station': '锁定电台', 'Unlock station': '解锁电台', 'Station locked': '电台已锁定', 'Station unlocked': '电台已解锁', 'Where am I?': '我在哪里?', 'your location': '你的位置', 'Going to nearest place': '正在前往最近的地点', 'Finding your location': '正在定位…', 'Planting seeds...': '正在播种…', 'Take a Balloon Ride': '乘坐热气球', 'Balloon Ride Radio': '热气球漫游电台', 'Balloon Ride': '热气球漫游', 'Take a ride': '开始旅程', 'Time Travel': '时光旅行', 'Clear': '清除', 'Done': '完成', 'Edit': '编辑', 'Exit': '退出', 'Open Radio Garden': '打开 Radio Garden', // 设置页 'Customize': '自定义', 'Dark Mode': '深色模式', 'Increased Contrast': '增强对比度', 'Globe Dots Size': '地球光点大小', 'Globe Quality': '地球画质', 'Sharper globe dots': '更清晰的光点', 'Sharper globe dots & imagery': '更清晰的光点与影像', 'Fastest performance': '最快性能', 'Small': '小', 'Normal': '正常', 'Large': '大', 'Extra Large': '超大', 'Low': '低', 'High': '高', 'Very High': '极高', 'Off': '关闭', 'On': '开启', 'Automatic': '自动', 'Automatic (Off)': '自动(关闭)', 'matches your system setting': '跟随系统设置', 'English': '英语', // 转移收藏 'Transfer Favorites': '转移收藏', 'Copy your favorites to another device:': '将收藏复制到其他设备:', 'Create a transfer code': '创建转移码', 'Creating transfer code': '正在创建转移码…', 'Enter a transfer code': '输入转移码', 'Enter the transfer code': '输入转移码', 'Enter your code': '输入你的代码', 'Send Favorites': '发送收藏', 'Receive Favorites': '接收收藏', 'Select Receive Favorites': '选择接收收藏', 'Submit Code': '提交代码', 'Share Code & Instructions': '分享代码与说明', 'This code expires in 3 days': '此代码将在 3 天后过期', // 提交电台 / 下载 App 'Submit a Radio Station': '提交电台', 'station submission form': '电台提交表单', 'fill in the station submission form': '填写电台提交表单', 'Get our free Android or iPhone app for your phone.': '为你的手机获取免费的 Android 或 iPhone 应用。', 'Available on the': '下载于', 'Get it on': '获取于', // 关于 / 团队 / 致谢 'Information': '信息', 'Contact': '联系', 'Contact Us': '联系我们', 'Team': '团队', 'Team Radio Garden': 'Radio Garden 团队', 'The Garden': '花园', 'Technologies & Services': '技术与服务', '3D globe –': '3D 地球 –', 'Content management –': '内容管理 –', 'Satellite imagery –': '卫星影像 –', 'Location data –': '位置数据 –', 'Typeface –': '字体 –', // 编辑精选 / 歌单描述 'Energetic Rhythms': '活力节奏', 'Independent Sounds': '独立之声', 'Musical Roots': '音乐根源', 'Different, distinctive, and experimental.': '与众不同、别具一格、充满实验性。', 'Electronic sounds and danceable rhythm patterns.': '电子音色与适合舞动的节奏。', 'Flavors of classical music with a global twist.': '带有全球风味的古典音乐。', 'Sail away to far away islands scattered across the oceans.': '扬帆驶向散落在大洋中的远方岛屿。', 'Stations connecting communities and groups living abroad.': '连接旅居海外社群的电台。', 'Zoom into the places that shaped musical genres.': '放大那些塑造了音乐流派的地方。', // 隐私政策(章节标题) 'Definitions': '定义', 'Data Controller': '数据控制者', 'Service Providers': '服务提供商', 'Service': '服务', 'Analytics': '分析', 'Advertising': '广告', 'Payments': '支付', 'Cookies': 'Cookie', 'Usage Data': '使用数据', 'Personal Data': '个人数据', 'Location Data': '位置数据', 'Use of Data': '数据的使用', 'Retention of Data': '数据的保留', 'Transfer of Data': '数据的传输', 'Disclosure of Data': '数据的披露', 'Security of Data': '数据的安全', "Children's Privacy": '儿童隐私', 'Changes to this Privacy Policy': '本隐私政策的变更', 'Links to Other Sites': '指向其他网站的链接', 'Information Collection and Use': '信息的收集与使用', 'Types of Data Collected': '收集的数据类型', 'Tracking & Cookies Data': '追踪与 Cookie 数据', 'Legal Requirements': '法律要求', 'Disclosure for Law Enforcement': '执法披露', 'Cookies and Usage Data': 'Cookie 与使用数据', 'Previous Updates': '过往更新', // 其他 'Terms': '条款', 'Privacy': '隐私', 'Terms of Use': '使用条款', 'Privacy Policy': '隐私政策', 'Language': '语言', 'Help': '帮助', 'Feedback': '反馈', }; // ===== 国家词典:英文 -> 中文 ===== // 只填国家名。城市名不要填(查不到会自动保持英文)。 const COUNTRIES = { // 常见国家/地区(按英文名排序) 'Afghanistan': '阿富汗', 'Albania': '阿尔巴尼亚', 'Algeria': '阿尔及利亚', 'Andorra': '安道尔', 'Angola': '安哥拉', 'Antigua and Barbuda': '安提瓜和巴布达', 'Argentina': '阿根廷', 'Armenia': '亚美尼亚', 'Australia': '澳大利亚', 'Austria': '奥地利', 'Azerbaijan': '阿塞拜疆', 'Bahamas': '巴哈马', 'Bahrain': '巴林', 'Bangladesh': '孟加拉国', 'Barbados': '巴巴多斯', 'Belarus': '白俄罗斯', 'Belgium': '比利时', 'Belize': '伯利兹', 'Benin': '贝宁', 'Bhutan': '不丹', 'Bolivia': '玻利维亚', 'Bosnia and Herzegovina': '波斯尼亚和黑塞哥维那', 'Botswana': '博茨瓦纳', 'Brazil': '巴西', 'Brunei': '文莱', 'Bulgaria': '保加利亚', 'Burkina Faso': '布基纳法索', 'Burundi': '布隆迪', 'Cabo Verde': '佛得角', 'Cambodia': '柬埔寨', 'Cameroon': '喀麦隆', 'Canada': '加拿大', 'Central African Republic': '中非共和国', 'Chad': '乍得', 'Chile': '智利', 'China': '中国', 'Colombia': '哥伦比亚', 'Comoros': '科摩罗', 'Congo (Congo-Brazzaville)': '刚果(布)', 'Congo (DRC)': '刚果(金)', 'Costa Rica': '哥斯达黎加', 'Côte d\'Ivoire': '科特迪瓦', 'Croatia': '克罗地亚', 'Cuba': '古巴', 'Cyprus': '塞浦路斯', 'Czech Republic': '捷克', 'Czechia': '捷克', 'Denmark': '丹麦', 'Djibouti': '吉布提', 'Dominica': '多米尼克', 'Dominican Republic': '多米尼加共和国', 'Ecuador': '厄瓜多尔', 'Egypt': '埃及', 'El Salvador': '萨尔瓦多', 'Equatorial Guinea': '赤道几内亚', 'Eritrea': '厄立特里亚', 'Estonia': '爱沙尼亚', 'Eswatini': '斯威士兰', 'Ethiopia': '埃塞俄比亚', 'Fiji': '斐济', 'Finland': '芬兰', 'France': '法国', 'Gabon': '加蓬', 'Gambia': '冈比亚', 'Georgia': '格鲁吉亚', 'Germany': '德国', 'Ghana': '加纳', 'Greece': '希腊', 'Grenada': '格林纳达', 'Guatemala': '危地马拉', 'Guinea': '几内亚', 'Guinea-Bissau': '几内亚比绍', 'Guyana': '圭亚那', 'Haiti': '海地', 'Honduras': '洪都拉斯', 'Hungary': '匈牙利', 'Iceland': '冰岛', 'India': '印度', 'Indonesia': '印度尼西亚', 'Iran': '伊朗', 'Iraq': '伊拉克', 'Ireland': '爱尔兰', 'Israel': '以色列', 'Italy': '意大利', 'Jamaica': '牙买加', 'Japan': '日本', 'Jordan': '约旦', 'Kazakhstan': '哈萨克斯坦', 'Kenya': '肯尼亚', 'Kiribati': '基里巴斯', 'Korea, North': '朝鲜', 'Korea, South': '韩国', 'North Korea': '朝鲜', 'South Korea': '韩国', 'Kosovo': '科索沃', 'Kuwait': '科威特', 'Kyrgyzstan': '吉尔吉斯斯坦', 'Laos': '老挝', 'Latvia': '拉脱维亚', 'Lebanon': '黎巴嫩', 'Lesotho': '莱索托', 'Liberia': '利比里亚', 'Libya': '利比亚', 'Liechtenstein': '列支敦士登', 'Lithuania': '立陶宛', 'Luxembourg': '卢森堡', 'Madagascar': '马达加斯加', 'Malawi': '马拉维', 'Malaysia': '马来西亚', 'Maldives': '马尔代夫', 'Mali': '马里', 'Malta': '马耳他', 'Marshall Islands': '马绍尔群岛', 'Mauritania': '毛里塔尼亚', 'Mauritius': '毛里求斯', 'Mexico': '墨西哥', 'Micronesia': '密克罗尼西亚', 'Moldova': '摩尔多瓦', 'Monaco': '摩纳哥', 'Mongolia': '蒙古', 'Montenegro': '黑山', 'Morocco': '摩洛哥', 'Mozambique': '莫桑比克', 'Myanmar (Burma)': '缅甸', 'Namibia': '纳米比亚', 'Nauru': '瑙鲁', 'Nepal': '尼泊尔', 'Netherlands': '荷兰', 'New Zealand': '新西兰', 'Nicaragua': '尼加拉瓜', 'Niger': '尼日尔', 'Nigeria': '尼日利亚', 'North Macedonia': '北马其顿', 'Norway': '挪威', 'Oman': '阿曼', 'Pakistan': '巴基斯坦', 'Palau': '帕劳', 'Palestine': '巴勒斯坦', 'Panama': '巴拿马', 'Papua New Guinea': '巴布亚新几内亚', 'Paraguay': '巴拉圭', 'Peru': '秘鲁', 'Philippines': '菲律宾', 'Poland': '波兰', 'Portugal': '葡萄牙', 'Qatar': '卡塔尔', 'Romania': '罗马尼亚', 'Russia': '俄罗斯', 'Rwanda': '卢旺达', 'Saint Kitts and Nevis': '圣基茨和尼维斯', 'Saint Lucia': '圣卢西亚', 'Saint Vincent and the Grenadines': '圣文森特和格林纳丁斯', 'Samoa': '萨摩亚', 'San Marino': '圣马力诺', 'Sao Tome and Principe': '圣多美和普林西比', 'Saudi Arabia': '沙特阿拉伯', 'Senegal': '塞内加尔', 'Serbia': '塞尔维亚', 'Seychelles': '塞舌尔', 'Sierra Leone': '塞拉利昂', 'Singapore': '新加坡', 'Slovakia': '斯洛伐克', 'Slovenia': '斯洛文尼亚', 'Solomon Islands': '所罗门群岛', 'Somalia': '索马里', 'South Africa': '南非', 'South Sudan': '南苏丹', 'Spain': '西班牙', 'Sri Lanka': '斯里兰卡', 'Sudan': '苏丹', 'Suriname': '苏里南', 'Sweden': '瑞典', 'Switzerland': '瑞士', 'Syria': '叙利亚', 'Taiwan': '台湾地区', // 按常用习惯,也可保留 '台湾' 'Tajikistan': '塔吉克斯坦', 'Tanzania': '坦桑尼亚', 'Thailand': '泰国', 'Timor-Leste': '东帝汶', 'Togo': '多哥', 'Tonga': '汤加', 'Trinidad and Tobago': '特立尼达和多巴哥', 'Tunisia': '突尼斯', 'Turkey': '土耳其', 'Turkmenistan': '土库曼斯坦', 'Tuvalu': '图瓦卢', 'Uganda': '乌干达', 'Ukraine': '乌克兰', 'United Arab Emirates': '阿联酋', 'United Kingdom': '英国', 'United States': '美国', 'Uruguay': '乌拉圭', 'Uzbekistan': '乌兹别克斯坦', 'Vanuatu': '瓦努阿图', 'Vatican City': '梵蒂冈', 'Venezuela': '委内瑞拉', 'Vietnam': '越南', 'Yemen': '也门', 'Zambia': '赞比亚', 'Zimbabwe': '津巴布韦', }; // 查表:是名字是国家则返回中文,否则返回 null function lookupCountry(name) { if (!name) return null; const k = name.trim(); return Object.prototype.hasOwnProperty.call(COUNTRIES, k) ? COUNTRIES[k] : null; } // 若 name 是国家则翻成中文,否则原样返回(用于模板里的城市/国家通用位置) function cn(name) { return lookupCountry(name) || name; } // 翻译单个片段:先查 UI 词典,再查国家,否则保持原样(电台/城市名) // 用于页面标题 "Radio Garden – X" 这类位置 function seg(name) { const k = (name || '').trim(); if (Object.prototype.hasOwnProperty.call(DICT, k)) return DICT[k]; return lookupCountry(k) || name; } // 翻译 "City, Country" 形式:城市保持英文,结尾国家译中文(查不到则原样) function transPlace(s) { const i = s.lastIndexOf(', '); if (i >= 0) { const country = lookupCountry(s.slice(i + 2)); if (country) return s.slice(0, i) + ', ' + country; } return s; } // ===== 模板规则:处理含动态内容(城市名/国家名/数字)的文案 ===== // 每条为 [正则, 构造函数],构造函数接收 match 数组,返回翻译后的字符串。 // 专有名词位置用 cn() 包一层:国家译为中文,城市保持英文。 const PATTERNS = [ [/^Stations in (.+)$/, (m) => `${cn(m[1])} 的电台`], [/^Popular in (.+)$/, (m) => `${cn(m[1])} 热门电台`], [/^Nearby (.+)$/, (m) => `${cn(m[1])} 附近`], [/^Cities in (.+)$/, (m) => `${cn(m[1])} 的城市`], [/^Go to (.+)$/, (m) => `前往 ${seg(m[1])}`], [/^© (\d+) Radio Garden BV\. All rights reserved\.?$/, (m) => `© ${m[1]} Radio Garden BV. 保留所有权利。`], [/^View all (\d+) stations$/, (m) => `查看全部 ${m[1]} 个电台`], [/^View (\d+) stations$/, (m) => `查看 ${m[1]} 个电台`], [/^View all (\d+) countries$/, (m) => `查看全部 ${m[1]} 个国家`], [/^(\d+) results found$/, (m) => `找到 ${m[1]} 条结果`], [/^(\d+) result found$/, (m) => `找到 ${m[1]} 条结果`], [/^Current location is (.+)$/, (m) => `当前位置:${transPlace(m[1])}`], [/^Loading from (.+)$/, (m) => `正在加载(来自 ${m[1]})`], // 页面标题 "Radio Garden – X":X 若是 UI 词条/国家则翻译,电台/城市名保持英文 [/^Radio Garden – (.+)$/, (m) => `Radio Garden – ${seg(m[1])}`], // 面包屑 "City, Country":城市保持英文,国家译中文(非国家则原样返回,不会改动) [/^.+, .+$/, (m) => transPlace(m[0])], ]; // 需要翻译的属性(placeholder / title / aria-label 等) const ATTRS = ['placeholder', 'title', 'aria-label', 'alt']; // 预处理:去掉首尾空白后比对 function translate(text) { if (!text) return null; const trimmed = text.trim(); if (!trimmed) return null; // 用函数形式替换,避免译文里的 $ 被当成 $1/$& 等替换占位符 if (Object.prototype.hasOwnProperty.call(DICT, trimmed)) { // 保留原有首尾空白 return text.replace(trimmed, () => DICT[trimmed]); } // 单独出现的国家名 const country = lookupCountry(trimmed); if (country) { return text.replace(trimmed, () => country); } // 再尝试模板规则 for (const [re, build] of PATTERNS) { const m = trimmed.match(re); if (m) { const out = build(m); if (out != null && out !== trimmed) { return text.replace(trimmed, () => out); } } } return null; } // 翻译后需要给按钮打标记修复布局的词条(英文原文) const BTN_LABELS = new Set(['Edit', 'Done']); // 处理单个文本节点 function handleTextNode(node) { const orig = (node.nodeValue || '').trim(); const next = translate(node.nodeValue); if (next !== null && next !== node.nodeValue) { node.nodeValue = next; // 中文比英文宽,给这些按钮打标记,靠 CSS 自适应宽度,避免重叠 if (BTN_LABELS.has(orig) && node.parentElement) { const el = node.parentElement; el.setAttribute('data-rg-zh-btn', ''); const btn = el.closest && el.closest('button, a, [role="button"]'); if (btn) btn.setAttribute('data-rg-zh-btn', ''); } } } // 处理元素的属性 function handleAttrs(el) { if (!el.getAttribute) return; for (const attr of ATTRS) { const val = el.getAttribute(attr); const next = translate(val); if (next !== null && next !== val) { el.setAttribute(attr, next); } } } // 遍历一个节点及其子孙 function walk(root) { if (!root) return; if (root.nodeType === Node.TEXT_NODE) { handleTextNode(root); return; } if (root.nodeType !== Node.ELEMENT_NODE) return; // 跳过脚本/样式 const tag = root.tagName; if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return; handleAttrs(root); // 用 TreeWalker 快速遍历文本节点 const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); let n; while ((n = tw.nextNode())) { handleTextNode(n); } // 元素属性也遍历一遍 const els = root.querySelectorAll('*'); for (const el of els) handleAttrs(el); } // 注入修复布局的 CSS(中文比英文宽,让被标记的按钮自适应、不换行、不重叠) function injectStyle() { if (document.getElementById('rg-zh-style')) return; const style = document.createElement('style'); style.id = 'rg-zh-style'; style.textContent = [ '[data-rg-zh-btn]{', ' white-space: nowrap !important;', ' width: auto !important;', ' min-width: max-content !important;', ' max-width: none !important;', ' overflow: visible !important;', ' flex: 0 0 auto !important;', '}', ].join('\n'); (document.head || document.documentElement).appendChild(style); } // 监听 SPA 动态渲染 function startObserver() { injectStyle(); walk(document.body); const observer = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === 'childList') { for (const node of m.addedNodes) walk(node); } else if (m.type === 'characterData') { handleTextNode(m.target); } else if (m.type === 'attributes') { handleAttrs(m.target); } } }); observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS, }); // 翻译