web-statics/scripts/translate.mjs
2026-10-22 22:19:50 +08:00

83 lines
5.5 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.

import path from 'node:path';
import { Converter } from 'opencc-js';
import { root, jsonFile, resource, pool, writeJSON } from './lib.mjs';
const toSimplified = Converter({from:'hk',to:'cn'});
const toTraditional = Converter({from:'cn',to:'hk'});
export const hasHan = s => /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(s);
const normalized = s => { const text=s.replace(/\s+/g,' ').trim();return /[\u3040-\u30ff\uac00-\ud7af]/.test(text)?text:toSimplified(text); };
const dictFile = path.join(root,'scripts','locales','en.json');
const overrides = {
'云服务器':'Cloud Server','产品':'Products','产品中心':'Products','关于我们':'About Us','公司简介':'Company Profile','公司介绍':'Company Profile','联系我们':'Contact Us',
'物理服务器':'Dedicated Server','独立服务器':'Dedicated Server','物理裸机':'Dedicated Server','裸金属服务器':'Bare Metal Server','负载均衡':'Load Balancer','高防CDN':'Anti-DDoS CDN',
'技术支持':'Support','技术文档':'Documentation','文档中心':'Documentation','帮助文档':'Documentation','帮助中心':'Help Center','公告与新闻':'News & Announcements','新闻资讯':'News','官方公告':'Announcements',
'CPU':'CPU','内存':'Memory','硬盘':'Storage','带宽':'Bandwidth','线路':'Network','价格':'Price','防护DDOS':'DDoS Protection','月付':'Monthly','月':'Month','地区':'Region','可选配置':'Available Configurations',
'搜索':'Search','全部':'All','上一页':'Previous','下一页':'Next','返回列表':'Back to List','查看详情':'View Details','更多':'More','暂无相关内容':'No content available.','未找到相关内容':'No results found.',
'首页':'Home','联系客服':'Contact Support','系统盘':'System Disk','数据盘':'Data Disk','公开起售价':'Starting Price','CPU(核)':'CPU (Cores)','选择语言':'Select Language','关闭':'Close',
};
let dictionary;
let saveQueue=Promise.resolve();
const save=()=>{saveQueue=saveQueue.then(()=>writeJSON(dictFile,dictionary));return saveQueue;};
async function getDictionary() { if(!dictionary) dictionary={...await jsonFile(dictFile,{}),...overrides}; return dictionary; }
export function textNodes($) {
const values=[];
function visit(node) {
if (node.type==='text') { if(hasHan(node.data)) values.push({node,value:node.data}); return; }
if (node.type==='comment' || ['script','style','code','pre','svg','noscript'].includes(node.name) || node.attribs?.['data-no-translate']!==undefined || /(?:language-option|language-name|language-code|current-lang-label)/.test(node.attribs?.class||'')) return;
for(const child of node.children||[]) visit(child);
}
visit($.root()[0]);
return values;
}
export async function prepareTranslations(documents) {
const dictionary=await getDictionary(); const keys=new Set();
for(const $ of documents) {
for(const item of textNodes($)) keys.add(normalized(item.value));
$('*[title], *[alt], *[placeholder], *[aria-label], meta[name="description"], meta[name="keywords"]').each((_,el)=> {
for(const attr of ['title','alt','placeholder','aria-label','content']) {const s=$(el).attr(attr);if(s&&hasHan(s))keys.add(normalized(s));}
});
}
const missing=[...keys].filter(s=>s && (!dictionary[s]||hasHan(dictionary[s])));
const batches=[];let batch=[];let length=0;
for(const key of missing) {if(length+key.length>1800&&batch.length){batches.push(batch);batch=[];length=0;}batch.push(key);length+=key.length+12;}
if(batch.length)batches.push(batch);
console.log(`Translations: ${keys.size} Chinese strings, ${missing.length} new, ${batches.length} batches`);
async function translateOne(value) {
const source=/[\u3040-\u30ff]/.test(value)?'ja':/[\uac00-\ud7af]/.test(value)?'ko':'auto';
const url='https://translate.googleapis.com/translate_a/single?client=gtx&sl='+source+'&tl=en&dt=t&q='+encodeURIComponent(value);
const r=await resource(url);if(r.status!==200)throw new Error(`Translation HTTP ${r.status}`);
const json=JSON.parse(r.body.toString());return json[0].map(part=>part[0]||'').join('');
}
let completed=0;
await pool(batches,3,async values=> {
const query=values.map((s,i)=>`ZX${String(i).padStart(4,'0')}: ${s}`).join('\n');
const translated=await translateOne(query);
const matches=[...translated.matchAll(/ZX\s*(\d{4})\s*[::]?\s*([\s\S]*?)(?=ZX\s*\d{4}\s*[::]?|$)/gi)];
for(let i=0;i<values.length;i++){
const found=matches.find(m=>Number(m[1])===i)?.[2]?.trim();
dictionary[values[i]]=found&&!hasHan(found)?found:await translateOne(values[i]);
if(hasHan(dictionary[values[i]]))throw new Error(`Untranslated text: ${values[i].slice(0,80)}`);
}
await save();
console.log(`Translations: ${++completed}/${batches.length} batches saved`);
});
await save();
}
export async function localize($,language) {
const dictionary=await getDictionary();
const convert=s=> {
if(!hasHan(s))return s;
if(language==='zh-cn')return toSimplified(s);
if(language==='zh-hk')return toTraditional(toSimplified(s));
const key=normalized(s);const translated=dictionary[key];
if(!translated)throw new Error(`Missing English translation: ${key.slice(0,100)}`);
return (s.match(/^\s*/)?.[0]||'')+translated+(s.match(/\s*$/)?.[0]||'');
};
for(const item of textNodes($))item.node.data=convert(item.value);
$('*[title], *[alt], *[placeholder], *[aria-label], meta[name="description"], meta[name="keywords"]').each((_,el)=>{
for(const attr of ['title','alt','placeholder','aria-label','content']){const s=$(el).attr(attr);if(s&&hasHan(s))$(el).attr(attr,convert(s));}
});
return $;
}