[CODE]

用 DeepL API 自動翻譯英文 RSS 訂閱源

做了一個開發者情報聚合器,主要抓英文內容,想自動翻成繁體中文。接 DeepL API 本身不難,難的是怎麼判斷哪些要翻、哪些不用。

2 min read
deepl translation nodejs automation

我在做一個個人 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_zhcontent_zhsummary_zh 欄位和原文並排)。這樣預設顯示中文,需要對照原文的時候也有,也不用每次讀取都重新翻譯。

去重是在資料庫層處理的:articles 表對 url 欄位有 UNIQUE 約束,同一篇文章第二次插入直接噴 constraint violation。沒有額外的 hash cache、也不需要多查一次——約束本身就是守門員。

有一個問題我還沒完全解決:技術術語的翻譯。有些詞彙翻出來理論上沒錯,但不是台灣工程師平常講的說法。“Observability” 翻成「可觀察性」是對的,但讀起來有點卡。現在的做法是對一些固定的行業術語,保留英文原詞放在括號裡,稍微手動維護一份清單。不夠優雅,但讀起來順多了。

翻譯品質

說實話,品質比我預期的好。那種工程師文化、技術領導力類型的長篇文章——也就是我大多數來源的主要內容——DeepL 翻出來已經夠流暢,不太需要再去對照英文原版。比較硬的技術深度文,偶爾還是會有一兩句讀起來怪怪的,但整體還是比 Google 翻譯好上不少。

對一個只有我在用的個人聚合器來說,這個品質完全夠了。