我在做一個個人 RSS 聚合器,抓了 17+ 個開發者領導力相關的英文來源。英文讀起來沒問題,但我想讓整個閱覽介面更貼近中文思維,所以決定加上自動翻譯。
選 DeepL 是因為我平常手動用它已經習慣了,處理技術內容的品質比 Google 翻譯好一個等級,尤其是一些需要語感的句子,翻出來比較像人話。免費方案每月有 500K 字元,平均每篇文章長度大概能翻 50 篇,對個人用途夠了。
先判斷要不要翻
翻之前要先知道這篇文章是不是英文。我沒有用第三方 library,而是自己寫了一個小偵測器,掃描 Unicode 字符範圍,回傳 ISO 639-3 語言代碼。
export function detectLanguage(text: string): string {
if (!text || text.length < 10) return 'en';
// CJK 統一表意文字,涵蓋繁體與簡體中文
const chineseChars = text.match(/[一-鿿㐀-䶿]/g);
if (chineseChars && chineseChars.length / text.length > 0.3) return 'cmn';
// 平假名、片假名
const japaneseChars = text.match(/[-ゟ゠-ヿ]/g);
if (japaneseChars && japaneseChars.length / text.length > 0.2) return 'jpn';
// 韓文
const koreanChars = text.match(/[가-ᄀ-ᇿ]/g);
if (koreanChars && koreanChars.length / text.length > 0.2) return 'kor';
return 'en';
}
邏輯很直接:如果超過 30% 的字符落在 CJK 統一表意文字範圍,就判定為中文(cmn);平假名/片假名超過 20% 是日文(jpn);韓文字符超過 20% 是韓文(kor);其他一律預設英文。不用裝任何依賴,字符比例的啟發式判斷對我處理的這類內容來說出乎意料地準。
我另外也設了標題加描述的最小字元總量,太短的直接跳過,不值得打一次 API。
程式碼區塊不能送去翻
技術文章裡有程式碼,這些絕對不能讓翻譯 API 碰。DeepL 有時候會把變數名稱或註解「翻譯」掉,範例程式直接壞掉。
我的做法是翻之前先把程式碼區塊拔掉,換成佔位符,翻完再塞回去:
function stripCodeBlocks(text) {
const blocks = [];
const stripped = text.replace(/```[\s\S]*?```/g, (match) => {
blocks.push(match);
return `[[CODE_BLOCK_${blocks.length - 1}]]`;
});
return { stripped, blocks };
}
function reinsertCodeBlocks(text, blocks) {
return text.replace(/\[\[CODE_BLOCK_(\d+)\]\]/g, (_, i) => blocks[i]);
}
如果 RSS 吐出來的是 HTML 格式,就直接用 DeepL 的 tag_handling=html 參數,它會自動只翻文字節點,HTML tag 完整保留,比手動處理乾淨。
打 DeepL API 和處理限流
API 呼叫本身很直接:
async function translate(text) {
const response = await fetch('https://api-free.deepl.com/v2/translate', {
method: 'POST',
headers: {
'Authorization': `DeepL-Auth-Key ${process.env.DEEPL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: [text],
target_lang: 'ZH',
tag_handling: 'html',
}),
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5');
await sleep(retryAfter * 1000);
return translate(text); // 重試一次
}
const data = await response.json();
return data.translations[0].text;
}
實際跑起來不太常碰到 429,因為文章是隨著 feed 更新慢慢進來的,不是一次性大批量處理。但早上很多來源同時發文的時候,偶爾會觸到每日字元上限,retry 邏輯還是有在發揮作用。
兩份版本都存起來
原文和譯文我分開存在資料庫裡(title_zh、content_zh、summary_zh 欄位和原文並排)。這樣預設顯示中文,需要對照原文的時候也有,也不用每次讀取都重新翻譯。
去重是在資料庫層處理的:articles 表對 url 欄位有 UNIQUE 約束,同一篇文章第二次插入直接噴 constraint violation。沒有額外的 hash cache、也不需要多查一次——約束本身就是守門員。
有一個問題我還沒完全解決:技術術語的翻譯。有些詞彙翻出來理論上沒錯,但不是台灣工程師平常講的說法。“Observability” 翻成「可觀察性」是對的,但讀起來有點卡。現在的做法是對一些固定的行業術語,保留英文原詞放在括號裡,稍微手動維護一份清單。不夠優雅,但讀起來順多了。
翻譯品質
說實話,品質比我預期的好。那種工程師文化、技術領導力類型的長篇文章——也就是我大多數來源的主要內容——DeepL 翻出來已經夠流暢,不太需要再去對照英文原版。比較硬的技術深度文,偶爾還是會有一兩句讀起來怪怪的,但整體還是比 Google 翻譯好上不少。
對一個只有我在用的個人聚合器來說,這個品質完全夠了。