翻譯系統完整部署文檔
一、架構說明
用戶 → PHP → 本地 Python 服務(127.0.0.1:8000)
│
├─ 階段1:詞霸(iciba)併發,限 30 條,總超時 2 秒
├─ 階段2:百度官方 API 兜底
└─ 階段3:返回原文
特點:
- 全國內,無跨境
- 詞霸免費
- 逐級降級
- 單次限 30 條,防止頁面卡死
- 30 條內秒回,超 30 條下次刷新繼續
二、Python 環境安裝
# ============================================================
# 1. 安裝 Miniconda
# ============================================================
cd /root
# 下載(北大鏡像,清華可能 403)
wget https://mirrors.pku.edu.cn/anaconda/miniconda/Miniconda3-py39_23.11.0-1-Linux-x86_64.sh
# 安裝(一路回車 + yes)
bash Miniconda3-py39_23.11.0-1-Linux-x86_64.sh
# 生效
source ~/.bashrc
conda --version
# ============================================================
# 2. 配置清華 conda 源(加速)
# ============================================================
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
conda config --set show_channel_urls yes
# ============================================================
# 3. 創建 Python 3.10 環境
# ============================================================
conda create -n translate python=3.10 -y
conda activate translate
# ============================================================
# 4. 安裝依賴(阿里雲源)
# ============================================================
pip install translators fastapi uvicorn -i https://mirrors.aliyun.com/pypi/simple/
# ============================================================
# 5. 創建服務目錄
# ============================================================
mkdir -p /www/wwwroot/translation_local
cd /www/wwwroot/translation_local
三、Python 服務文件
路徑:/www/wwwroot/translation_local/translation_service.py
from fastapi import FastAPI
from pydantic import BaseModel, Field
import translators as ts
import re
import time
from concurrent.futures import ThreadPoolExecutor
app = FastAPI(title="本地翻譯", version="3.0.0")
SEPARATOR = "\n<<<__A6_SEP_9x7k2p__>>>\n"
class TranslateRequest(BaseModel):
sl: str = Field("zh-CN")
tl: str = Field("en")
q: str = Field(..., min_length=1)
def has_chinese(text):
return bool(re.search(r'[\u4e00-\u9fff]', text))
def split_by_sep(text):
return text.split(SEPARATOR) if SEPARATOR in text else [text]
def lang_to_ts(lang):
m = {'zh-CN': 'zh', 'zh-TW': 'cht', 'zh': 'zh', 'ja': 'ja', 'jp': 'ja',
'ko': 'ko', 'kor': 'ko', 'fr': 'fr', 'fra': 'fr'}
return m.get(lang, lang)
def translate_single(text, from_lang, to_lang):
try:
r = ts.translate_text(text, translator='iciba',
from_language=from_lang, to_language=to_lang)
return str(r) if r and str(r).strip() else None
except Exception:
return None
def translate_batch(texts, from_lang, to_lang, workers=10):
results = [None] * len(texts)
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {
pool.submit(translate_single, t, from_lang, to_lang): i
for i, t in enumerate(texts)
}
for fut in futures:
idx = futures[fut]
try:
results[idx] = fut.result(timeout=5)
except Exception:
results[idx] = None
return results
def translate_pipeline(q, sl, tl):
from_ts = lang_to_ts(sl)
to_ts = lang_to_ts(tl)
parts = split_by_sep(q)
total = len(parts)
print(f"[翻譯] {sl}→{tl} | {total} 段", flush=True)
t0 = time.time()
iciba_results = translate_batch(parts, from_ts, to_ts, workers=10)
cost = time.time() - t0
final_parts = []
failed = 0
for src, trans in zip(parts, iciba_results):
s = src.strip()
t = (trans or '').strip()
if not t:
final_parts.append(src)
failed += 1
elif t == s and has_chinese(s):
final_parts.append(src)
failed += 1
else:
final_parts.append(t)
print(f"[完成] 成功 {total - failed}/{total},失敗 {failed}({cost:.2f}s)", flush=True)
return SEPARATOR.join(final_parts)
@app.post("/translate")
async def translate(request: TranslateRequest):
result = translate_pipeline(request.q, request.sl, request.tl)
return [
[[result, request.q, None, None, 10]],
None,
request.sl,
None,
None,
None,
1.0,
]
@app.get("/health")
async def health():
return {"status": "ok"}
四、Supervisor 守護配置
# ============================================================
# 1. 安裝 supervisor(國內服務器通常沒裝)
# ============================================================
yum install -y supervisor
systemctl enable supervisord
systemctl start supervisord
# ============================================================
# 2. 創建配置文件
# ============================================================
cat > /etc/supervisord.d/translation_local.ini << 'EOF'
[program:translation_local]
command=/root/miniconda3/envs/translate/bin/uvicorn translation_service:app --host 127.0.0.1 --port 8000 --workers 2
directory=/www/wwwroot/translation_local
autostart=true
autorestart=true
startretries=3
user=root
redirect_stderr=true
stdout_logfile=/www/wwwroot/translation_local/service.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
environment=PATH="/root/miniconda3/envs/translate/bin:%(ENV_PATH)s"
EOF
# ============================================================
# 3. 啓動服務
# ============================================================
supervisorctl reread
supervisorctl update
supervisorctl status
# ============================================================
# 4. 測試
# ============================================================
curl -s -X POST http://127.0.0.1:8000/translate \
-H "Content-Type: application/json" \
-d '{"sl":"zh-CN","tl":"ja","q":"今天天氣不錯"}'
五、常用管理命令
# 查看狀態
supervisorctl status
# 重啓
supervisorctl restart translation_local
# 停止
supervisorctl stop translation_local
# 啓動
supervisorctl start translation_local
# 即時看日誌
tail -f /www/wwwroot/translation_local/service.log
# 內存佔用
free -h
# 端口監聽
ss -tlnp | grep 8000
六、PHP 翻譯類
<?php
if (!defined('A6_ENTRY')) { http_response_code(403); exit('Forbidden'); }
/**
* 多引擎翻譯器 v5.5
*
* v5.5 變更(相對 v5.4.1):
* - 動態超時控制:cURL 超時 = 剩餘可用時間,不再死等 8 秒
* - 單次翻譯限制 30 條(maxBatchSize)
* - 總超時 2 秒,超時立即停止本地翻譯
* - 去掉 cURL 重試,一次超時立即返回
* - 百度兜底改爲 translateWithBaidu 方法
*
* 工作流:
* 1. 緩存命中
* 2. 本地詞霸(限 30 條 + 總超時 2 秒)
* 3. 百度兜底
* 4. 返回原文
*/
$__autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($__autoload)) {
require_once $__autoload;
}
use Overtrue\PHPOpenCC\OpenCC;
class BaiduTranslator
{
private static $failureCacheTtl = 10;
private static $failureCacheTtlLoaded = false;
private $appId;
private $secretKey;
private $baiduApiUrl;
private $googleProxyUrl;
private $supportedLangs;
private $currentLang;
private $cacheTtl;
private $debug;
private $cacheSys;
private $memo = array();
private $engineChain = ['google', 'baidu'];
private $googleMaxChars = 5000;
private $baiduMaxChars = 5500;
private $googleBatchSeparator = "\n<<<__A6_SEP_9x7k2p__>>>\n";
private $googleSourceLang = 'zh-CN';
private $baiduSourceLang = 'zh';
private $qps = 10;
private $lastRequestTime = 0.0;
private $lastErrorCount = 0;
private $googleFailureRecordedThisRequest = false;
private $converterAvailable = false;
// ============================================================
// ★ v5.5:超時控制參數
// ============================================================
const MAX_BATCH_SIZE = 30; // 單次最多翻譯條數
const TOTAL_TIMEOUT = 2.0; // 本地翻譯總超時(秒)
const CHUNK_SIZE = 10; // 分塊大小(條)
const PROXY_TIMEOUT = 2; // 默認單次 cURL 超時(秒)
const PROXY_CONNECT_TIMEOUT = 1; // 連接超時(秒)
// ============================================================
// 其他常量
// ============================================================
const BAIDU_DISABLED_KEY = 'baidu_disabled_until';
const GOOGLE_BREAKER_KEY = 'google_breaker_state';
const BAIDU_INSUFFICIENT_BALANCE = '54004';
const BAIDU_SERVICE_CLOSE = '58002';
const GOOGLE_FAIL_THRESHOLD = 50;
const GOOGLE_COOLDOWN_SECONDS = 300;
const JAKO_SHORT_WORD_MAX_LEN = 4;
const BOT_UA_PATTERN = '/Googlebot|bingbot|Baiduspider|YandexBot|DuckDuckBot|AhrefsBot|SemrushBot|MJ12bot|DotBot|PetalBot|Bytespider/i';
public static function isBotRequest()
{
if (defined('SKIP_TRANSLATION') && SKIP_TRANSLATION) {
return true;
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
return (bool)preg_match(self::BOT_UA_PATTERN, $ua);
}
private static function loadFailureCacheTtl()
{
if (self::$failureCacheTtlLoaded) return;
self::$failureCacheTtlLoaded = true;
if (defined('TRANSLATE_FAILURE_CACHE_TTL')) {
$v = (int)TRANSLATE_FAILURE_CACHE_TTL;
if ($v > 0) self::$failureCacheTtl = $v;
}
}
public static function setFailureCacheTtl($seconds)
{
self::$failureCacheTtl = max(1, (int)$seconds);
}
public static function getFailureCacheTtl()
{
return self::$failureCacheTtl;
}
public function __construct($config = [])
{
self::loadFailureCacheTtl();
$this->appId = $config['app_id'] ?? '';
$this->secretKey = $config['secret_key'] ?? '';
$this->baiduApiUrl = $config['baidu_api_url'] ?? 'https://fanyi-api.baidu.com/api/trans/vip/translate';
$this->googleProxyUrl = $config['google_proxy_url'] ?? 'http://127.0.0.1:8000/translate';
$this->supportedLangs = $config['supported_langs'] ?? [
'zh', 'en', 'jp', 'kor', 'fra', 'spa', 'th', 'ara', 'ru', 'pt',
'de', 'it', 'el', 'nl', 'pl', 'bul', 'est', 'dan', 'fin', 'cs',
'rom', 'slo', 'swe', 'hu', 'vie', 'cht'
];
$this->currentLang = $config['default_lang'] ?? 'zh';
$this->cacheTtl = $config['cache_ttl'] ?? 2592000;
$this->debug = defined('DEBUG_TRANSLATION')
? DEBUG_TRANSLATION
: ($config['debug'] ?? false);
if (isset($config['engine_chain']) && is_array($config['engine_chain'])) {
$this->engineChain = $config['engine_chain'];
}
if (isset($config['google_max_chars'])) {
$this->googleMaxChars = (int)$config['google_max_chars'];
}
if (isset($config['baidu_max_chars'])) {
$this->baiduMaxChars = (int)$config['baidu_max_chars'];
}
if (isset($config['qps'])) {
$this->qps = (int)$config['qps'];
}
$this->cacheSys = get_cache_instance('Mran', 1, 'sys');
$this->converterAvailable = class_exists('Overtrue\\PHPOpenCC\\OpenCC');
}
// ============================================================
// 緩存讀寫
// ============================================================
private function getCache($lang)
{
return get_cache_instance('Mran', 1, $lang);
}
private function getText($text, $targetLang)
{
if ($text === '') return false;
$cacheNew = $this->getCache($targetLang);
$result = $cacheNew->get(md5($text));
if ($result === '' || $result === null || $result === false) return false;
return $result;
}
private function getTexts($texts, $targetLang)
{
if (empty($texts)) return array();
$cacheNew = $this->getCache($targetLang);
$result = array();
$textByMd5 = array();
$newKeys = array();
foreach ($texts as $text) {
$n = md5($text);
$textByMd5[$n] = $text;
$newKeys[] = $n;
}
$cached = $cacheNew->getMulti($newKeys);
foreach ($cached as $n => $translated) {
if ($translated === '' || $translated === null) continue;
if (isset($textByMd5[$n])) {
$result[$textByMd5[$n]] = $translated;
}
}
return $result;
}
// ============================================================
// 公開 API
// ============================================================
public function setCurrentLang($lang)
{
if (in_array($lang, $this->supportedLangs)) {
$this->currentLang = $lang;
}
return $this;
}
public function getCurrentLang() { return $this->currentLang; }
public function getLastErrorCount() { return $this->lastErrorCount; }
// ============================================================
// 翻譯校驗
// ============================================================
private function decideCacheAction($source, $translated, $targetLang = 'en')
{
if ($translated === '' || $translated === null) {
return ['cache' => true, 'value' => $source, 'ttl' => self::$failureCacheTtl, 'reason' => 'empty-translation'];
}
if ($translated !== $source) {
return ['cache' => true, 'value' => $translated, 'ttl' => 0, 'reason' => 'translated'];
}
$hasChinese = preg_match('/[\x{4e00}-\x{9fff}]/u', $source);
if ($hasChinese) {
$isJaKo = in_array($targetLang, ['jp', 'kor'], true);
$sourceLen = mb_strlen($source, 'UTF-8');
if ($isJaKo && $sourceLen < self::JAKO_SHORT_WORD_MAX_LEN) {
return ['cache' => true, 'value' => $translated, 'ttl' => 0, 'reason' => 'jako-short-word'];
}
return ['cache' => true, 'value' => $source, 'ttl' => self::$failureCacheTtl, 'reason' => 'same-with-chinese'];
}
return ['cache' => true, 'value' => $translated, 'ttl' => 0, 'reason' => 'no-translation-needed'];
}
// ============================================================
// HTML 轉義
// ============================================================
private function escapeTranslatedText($text)
{
if ($text === '' || $text === null) return $text;
$len = strlen($text);
$result = '';
$i = 0;
while ($i < $len) {
$c = $text[$i];
if ($c === '&') {
$rest = substr($text, $i);
if (preg_match('/^&(?:[a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/', $rest, $m)) {
$result .= $m[0];
$i += strlen($m[0]);
continue;
}
$result .= '&';
$i++;
continue;
}
if ($c === '<') { $result .= '<'; $i++; continue; }
if ($c === '>') { $result .= '>'; $i++; continue; }
if ($c === '"') { $result .= '"'; $i++; continue; }
if ($c === "'") { $result .= '''; $i++; continue; }
$result .= $c;
$i++;
}
return $result;
}
// ============================================================
// 語言代碼
// ============================================================
private function getBaiduLangCode($lang)
{
$map = [
'zh'=>'zh','en'=>'en','jp'=>'jp','cht'=>'cht','kor'=>'kor',
'fra'=>'fra','spa'=>'spa','th'=>'th','ara'=>'ara','ru'=>'ru',
'pt'=>'pt','de'=>'de','it'=>'it','el'=>'el','nl'=>'nl',
'pl'=>'pl','bul'=>'bul','est'=>'est','dan'=>'dan','fin'=>'fin',
'cs'=>'cs','rom'=>'rom','slo'=>'slo','swe'=>'swe','hu'=>'hu','vie'=>'vie',
];
return $map[$lang] ?? $lang;
}
private function getGoogleLangCode($lang)
{
$map = [
'zh'=>'zh-CN','cht'=>'zh-TW','en'=>'en','jp'=>'ja','kor'=>'ko',
'fra'=>'fr','spa'=>'es','th'=>'th','ara'=>'ar','ru'=>'ru',
'pt'=>'pt','de'=>'de','it'=>'it','el'=>'el','nl'=>'nl',
'pl'=>'pl','bul'=>'bg','est'=>'et','dan'=>'da','fin'=>'fi',
'cs'=>'cs','rom'=>'ro','slo'=>'sk','swe'=>'sv','hu'=>'hu','vie'=>'vi',
];
return $map[$lang] ?? $lang;
}
// ============================================================
// 日誌
// ============================================================
private function L($indent, $msg)
{
if (!$this->debug) return;
$pad = str_repeat(' ', $indent);
echo '<!-- ' . $pad . $msg . " -->\n";
}
private function shortText($text, $maxLen = 60)
{
$t = str_replace(["\n", "\r", "\t"], ['\\n', '\\r', '\\t'], $text);
if (mb_strlen($t, 'UTF-8') > $maxLen) {
$t = mb_substr($t, 0, $maxLen, 'UTF-8') . '…';
}
return $t;
}
// ============================================================
// 限速 / 規範化
// ============================================================
private function throttle()
{
$interval = 1.0 / max(1, $this->qps);
$now = microtime(true);
$elapsed = $now - $this->lastRequestTime;
if ($elapsed < $interval) {
$sleepUs = (int)(($interval - $elapsed) * 1000000);
if ($sleepUs > 0) usleep($sleepUs);
}
$this->lastRequestTime = microtime(true);
}
private function normalizeText($text)
{
$text = trim($text);
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
$text = str_replace(["\r\n", "\n", "\r"], ' ', $text);
$text = preg_replace('/\s+/u', ' ', $text);
return trim($text);
}
// ============================================================
// HTTP
// ============================================================
private function httpGet($url, $timeout = 30)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36');
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) return ['error' => $err];
return ['body' => $body];
}
/**
* ★ v5.5:支持動態超時參數
*/
private function httpPostJson($url, $jsonPayload, $timeout = null)
{
if ($timeout === null) $timeout = self::PROXY_TIMEOUT;
// 最小 1 秒,避免 0 或負數
$timeout = max(1, (int)ceil($timeout));
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(self::PROXY_CONNECT_TIMEOUT, $timeout));
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) return ['error' => $err];
return ['body' => $body];
}
private function httpPostForm($url, $postData, $timeout = 15)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_FORBID_REUSE, true);
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) return ['error' => $err];
return ['body' => $body];
}
// ============================================================
// 百度禁用
// ============================================================
private function isBaiduDisabled()
{
$until = $this->cacheSys->get(self::BAIDU_DISABLED_KEY);
if ($until === false) return false;
$untilTs = (int)$until;
if ($untilTs <= 0) return false;
return time() < $untilTs;
}
private function disableBaiduUntilNextMonth()
{
$nextMonth = strtotime('first day of next month 00:00:00');
$this->cacheSys->set(self::BAIDU_DISABLED_KEY, $nextMonth, 40 * 86400);
$this->L(0, '⚠ 百度翻譯禁用,禁用至 ' . date('Y-m-d H:i:s', $nextMonth));
error_log('[Translator] 百度翻譯禁用,恢復時間 ' . date('Y-m-d H:i:s', $nextMonth));
}
private function isBaiduDisableSignal($errorCode)
{
return in_array((string)$errorCode, [
self::BAIDU_INSUFFICIENT_BALANCE,
self::BAIDU_SERVICE_CLOSE,
], true);
}
// ============================================================
// Google 熔斷
// ============================================================
private function getGoogleBreakerState()
{
$raw = $this->cacheSys->get(self::GOOGLE_BREAKER_KEY);
if ($raw === false) return ['fails' => 0, 'until' => 0];
$state = json_decode($raw, true);
if (!is_array($state)) return ['fails' => 0, 'until' => 0];
return [
'fails' => (int)($state['fails'] ?? 0),
'until' => (int)($state['until'] ?? 0),
];
}
private function saveGoogleBreakerState($state)
{
$this->cacheSys->set(self::GOOGLE_BREAKER_KEY, json_encode($state), 86400);
}
private function isGoogleDisabled()
{
$state = $this->getGoogleBreakerState();
if ($state['until'] <= 0) return false;
if (time() >= $state['until']) {
$state['until'] = 0;
$state['fails'] = 0;
$this->saveGoogleBreakerState($state);
$this->L(0, 'Google 熔斷已過期,自動恢復');
return false;
}
return true;
}
private function recordGoogleFailure()
{
if ($this->googleFailureRecordedThisRequest) return;
$this->googleFailureRecordedThisRequest = true;
$state = $this->getGoogleBreakerState();
$state['fails']++;
$this->L(0, 'Google 失敗計數 +1,當前 ' . $state['fails']
. '/' . self::GOOGLE_FAIL_THRESHOLD);
if ($state['fails'] >= self::GOOGLE_FAIL_THRESHOLD) {
$state['until'] = time() + self::GOOGLE_COOLDOWN_SECONDS;
$state['fails'] = 0;
$this->L(0, 'Google 累計失敗 ' . self::GOOGLE_FAIL_THRESHOLD
. ' 次,熔斷至 ' . date('Y-m-d H:i:s', $state['until']));
error_log('[Translator] Google 熔斷至 ' . date('Y-m-d H:i:s', $state['until']));
}
$this->saveGoogleBreakerState($state);
}
private function recordGoogleSuccess()
{
$state = $this->getGoogleBreakerState();
if ($state['fails'] > 0) {
$state['fails'] = 0;
$this->saveGoogleBreakerState($state);
$this->L(0, 'Google 成功,失敗計數清零');
}
}
// ============================================================
// 引擎可用性
// ============================================================
private function isEngineAvailable($engine)
{
switch ($engine) {
case 'google':
if (empty($this->googleProxyUrl)) {
$this->L(1, '引擎 google 不可用:中轉地址未配置');
return false;
}
if ($this->isGoogleDisabled()) {
$state = $this->getGoogleBreakerState();
$this->L(1, '引擎 google 熔斷中,恢復時間 '
. date('Y-m-d H:i:s', $state['until']));
return false;
}
return true;
case 'baidu':
if (empty($this->appId) || empty($this->secretKey)) {
$this->L(1, '引擎 baidu 不可用:APP ID 或密鑰未配置');
return false;
}
if ($this->isBaiduDisabled()) {
$until = (int)$this->cacheSys->get(self::BAIDU_DISABLED_KEY);
$this->L(1, '引擎 baidu 禁用中,恢復時間 '
. date('Y-m-d H:i:s', $until));
return false;
}
return true;
default:
return false;
}
}
// ============================================================
// 引擎 1:詞霸(本地 Python)★ v5.5 支持動態超時
// ============================================================
private function callGoogleApi($query, $from, $to, $timeout = null)
{
if (empty($this->googleProxyUrl)) {
return ['error' => 'Google 中轉地址未配置', 'engine' => 'google'];
}
$this->throttle();
if ($timeout === null) $timeout = self::PROXY_TIMEOUT;
$payload = json_encode([
'sl' => $from,
'tl' => $to,
'q' => $query,
], JSON_UNESCAPED_UNICODE);
// ★ v5.5:去掉重試,一次超時立即返回
$r = $this->httpPostJson($this->googleProxyUrl, $payload, $timeout);
if (isset($r['error'])) {
return ['error' => 'cURL error: ' . $r['error'], 'engine' => 'google'];
}
$response = $r['body'];
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['error' => 'JSON解析失敗: ' . json_last_error_msg(), 'engine' => 'google'];
}
if (!is_array($decoded) || !isset($decoded[0]) || !is_array($decoded[0])) {
if (isset($decoded['error'])) {
return ['error' => '中轉錯誤: ' . $decoded['error'], 'engine' => 'google'];
}
return ['error' => 'Google 返回結構異常', 'engine' => 'google'];
}
$translated = '';
foreach ($decoded[0] as $seg) {
if (isset($seg[0])) $translated .= $seg[0];
}
return [
'trans_result' => [['src' => $query, 'dst' => $translated]],
'engine' => 'google',
];
}
// ============================================================
// 引擎 2:百度
// ============================================================
private function callBaiduApi($query, $from, $to)
{
if (empty($this->appId) || empty($this->secretKey)) {
return ['error' => '百度翻譯未配置', 'engine' => 'baidu'];
}
$this->throttle();
$salt = rand(10000, 99999);
$sign = md5($this->appId . $query . $salt . $this->secretKey);
$args = [
'q' => $query,
'appid' => $this->appId,
'salt' => $salt,
'from' => $from,
'to' => $to,
'sign' => $sign,
];
$postData = http_build_query($args);
$r = $this->httpPostForm($this->baiduApiUrl, $postData, 15);
if (isset($r['error'])) {
return ['error' => 'cURL error: ' . $r['error'], 'engine' => 'baidu'];
}
$decoded = json_decode($r['body'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['error' => 'JSON解析失敗: ' . json_last_error_msg(), 'engine' => 'baidu'];
}
$decoded['engine'] = 'baidu';
return $decoded;
}
private function getSourceLang($engine)
{
return ($engine === 'google') ? $this->googleSourceLang : $this->baiduSourceLang;
}
private function engineLabel($engine)
{
if ($engine === 'google') return '詞霸(本地 Python)';
if ($engine === 'baidu') return '百度翻譯(官方 API)';
return $engine;
}
// ============================================================
// 百度批量兜底
// ============================================================
private function translateWithBaidu($texts, $targetLang)
{
$results = [];
$from = $this->baiduSourceLang;
$to = $this->getBaiduLangCode($targetLang);
$chunks = array_chunk($texts, 10);
foreach ($chunks as $chunk) {
$query = implode("\n", $chunk);
$apiResult = $this->callBaiduApi($query, $from, $to);
if (isset($apiResult['error_code'])) {
if ($this->isBaiduDisableSignal($apiResult['error_code'])) {
$this->disableBaiduUntilNextMonth();
}
$this->L(1, '百度 API 錯誤:' . $apiResult['error_code']);
continue;
}
if (!empty($apiResult['trans_result'])) {
$parts = [];
foreach ($apiResult['trans_result'] as $row) {
$parts[] = $row['dst'] ?? '';
}
if (count($parts) === count($chunk)) {
foreach ($chunk as $i => $text) {
$results[$text] = $parts[$i];
}
}
}
}
return $results;
}
// ============================================================
// ★ v5.5:批量翻譯主入口(動態超時 + 限條數)
// ============================================================
private function translateBatch($texts, $targetLang)
{
if (empty($texts)) return [];
$texts = array_values(array_filter($texts, function ($t) {
return trim($t) !== '';
}));
if (empty($texts)) return [];
// ① 從緩存獲取已翻譯結果
$cached = $this->getTexts($texts, $targetLang);
$toTranslate = array();
foreach ($texts as $text) {
if (!isset($cached[$text])) {
$toTranslate[] = $text;
}
}
$this->L(1, '【translateBatch】輸入 ' . count($texts)
. ' 條 → 緩存命中 ' . count($cached)
. ',需翻譯 ' . count($toTranslate));
if (empty($toTranslate)) return $cached;
// ② 限制單次翻譯條數
if (count($toTranslate) > self::MAX_BATCH_SIZE) {
$toTranslate = array_slice($toTranslate, 0, self::MAX_BATCH_SIZE);
$this->L(1, ' 限制單次翻譯條數爲 ' . self::MAX_BATCH_SIZE . ' 條');
}
$startTime = microtime(true);
$cacheNew = $this->getCache($targetLang);
// ============================================================
// ③ 階段1:本地詞霸(動態超時 + 分塊)
// ============================================================
$this->L(0, '');
$this->L(0, '▶ 階段1:本地翻譯(詞霸)');
$this->L(0, ' 超時限制:' . self::TOTAL_TIMEOUT . '秒');
$this->L(0, '');
$engineResults = [];
$failedTexts = [];
$chunks = array_chunk($toTranslate, self::CHUNK_SIZE);
$from = $this->getSourceLang('google');
$to = $this->getGoogleLangCode($targetLang);
foreach ($chunks as $chunkIdx => $chunk) {
$elapsed = microtime(true) - $startTime;
$remaining = self::TOTAL_TIMEOUT - $elapsed;
if ($remaining <= 0) {
$this->L(1, ' ⏱ 總超時(' . round($elapsed, 2) . 's),停止本地翻譯');
// 剩餘塊全部標記爲失敗
for ($i = $chunkIdx; $i < count($chunks); $i++) {
$failedTexts = array_merge($failedTexts, $chunks[$i]);
}
break;
}
$this->L(1, ' 翻譯第 ' . ($chunkIdx + 1) . '/' . count($chunks)
. ' 塊(' . count($chunk) . ' 條),剩餘 ' . round($remaining, 2) . 's');
$query = implode($this->googleBatchSeparator, $chunk);
$apiResult = $this->callGoogleApi($query, $from, $to, $remaining);
if (isset($apiResult['error'])) {
$this->L(2, ' ✘ 塊 ' . ($chunkIdx + 1) . ' 失敗:' . $apiResult['error']);
$failedTexts = array_merge($failedTexts, $chunk);
if (strpos($apiResult['error'], 'timed out') !== false) {
// 超時錯誤,剩餘塊也別試了
for ($i = $chunkIdx + 1; $i < count($chunks); $i++) {
$failedTexts = array_merge($failedTexts, $chunks[$i]);
}
break;
}
continue;
}
// 檢查結果
$combined = '';
foreach ($apiResult['trans_result'] as $row) {
$combined .= $row['dst'] ?? '';
}
$parts = explode($this->googleBatchSeparator, $combined);
$parts = array_map(function ($s) { return trim($s, " \t\n\r"); }, $parts);
if (count($parts) !== count($chunk)) {
$this->L(2, ' ✘ 塊 ' . ($chunkIdx + 1) . ' 段數不匹配('
. count($parts) . '/' . count($chunk) . ')');
$failedTexts = array_merge($failedTexts, $chunk);
continue;
}
foreach ($chunk as $i => $text) {
$engineResults[$text] = $parts[$i];
}
$this->L(2, ' ✔ 塊 ' . ($chunkIdx + 1) . ' 成功');
}
$localCost = microtime(true) - $startTime;
$this->L(1, '階段1完成:成功 ' . count($engineResults)
. ' 條,失敗 ' . count($failedTexts)
. ' 條,耗時 ' . round($localCost, 2) . 's');
// ============================================================
// ④ 階段2:百度兜底
// ============================================================
$baiduResults = [];
if (!empty($failedTexts)) {
if ($this->isEngineAvailable('baidu')) {
$this->L(0, '');
$this->L(0, '▶ 階段2:百度翻譯兜底(' . count($failedTexts) . ' 條)');
$this->L(0, '');
$baiduResults = $this->translateWithBaidu($failedTexts, $targetLang);
$this->L(1, '百度翻譯完成:成功 ' . count($baiduResults) . ' 條');
} else {
$this->L(0, '');
$this->L(0, '⚠ 百度翻譯不可用,失敗文本返回原文');
}
}
// ============================================================
// ⑤ 合併結果,寫入緩存
// ============================================================
$goodResults = [];
// 本地成功 + 校驗通過
foreach ($engineResults as $src => $dst) {
$action = $this->decideCacheAction($src, $dst, $targetLang);
if ($action['reason'] === 'same-with-chinese'
|| $action['reason'] === 'empty-translation') {
// 本地無效,嘗試百度
if (isset($baiduResults[$src])) {
$baiduAction = $this->decideCacheAction($src, $baiduResults[$src], $targetLang);
if ($baiduAction['reason'] !== 'same-with-chinese'
&& $baiduAction['reason'] !== 'empty-translation') {
$goodResults[$src] = $baiduAction['value'];
}
}
} else {
$goodResults[$src] = $action['value'];
}
}
// 百度兜底 + 校驗通過(本地失敗的)
foreach ($baiduResults as $src => $dst) {
if (isset($goodResults[$src])) continue;
$action = $this->decideCacheAction($src, $dst, $targetLang);
if ($action['reason'] !== 'same-with-chinese'
&& $action['reason'] !== 'empty-translation') {
$goodResults[$src] = $action['value'];
}
}
// 寫入緩存
if (!empty($goodResults)) {
$toWrite = [];
foreach ($goodResults as $src => $dst) {
$toWrite[md5($src)] = $dst;
}
$cacheNew->setMulti($toWrite, 0);
$cached = array_merge($cached, $goodResults);
$this->L(1, '寫入緩存:' . count($goodResults) . ' 條');
}
// 仍然失敗的返回原文
$stillFailed = 0;
foreach ($toTranslate as $text) {
if (!isset($goodResults[$text])) {
$cached[$text] = $text;
$stillFailed++;
}
}
if ($stillFailed > 0) {
$this->L(1, '最終失敗返回原文:' . $stillFailed . ' 條');
$this->lastErrorCount += $stillFailed;
}
// 補齊
$finalResult = [];
foreach ($texts as $text) {
$finalResult[$text] = isset($cached[$text]) ? $cached[$text] : $text;
}
return $finalResult;
}
// ============================================================
// 單條翻譯
// ============================================================
public function translate($text, $targetLang)
{
$text = $this->normalizeText($text);
if (empty($text) || $targetLang === 'zh') return $text;
if (!in_array($targetLang, $this->supportedLangs)) return $text;
$memoKey = $targetLang . '|' . $text;
if (isset($this->memo[$memoKey])) return $this->memo[$memoKey];
$cached = $this->getText($text, $targetLang);
if ($cached !== false) {
$this->memo[$memoKey] = $cached;
return $cached;
}
$result = $this->translateBatch(array($text), $targetLang);
$translated = isset($result[$text]) ? $result[$text] : $text;
$this->memo[$memoKey] = $translated;
return $translated;
}
// ============================================================
// DOM 工具
// ============================================================
private function loadHtmlDocument($html)
{
$dom = new DOMDocument();
$dom->encoding = 'UTF-8';
$encoded = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x10FFFF], 'UTF-8');
@$dom->loadHTML($encoded, LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING);
return $dom;
}
private function saveHtmlClean($dom)
{
$html = $dom->saveHTML();
$html = preg_replace('/^<\?xml[^>]*>/i', '', $html);
$html = preg_replace('/^<!DOCTYPE[^>]*>/i', '', $html);
$html = preg_replace('/<\/?(html|head|body|meta)[^>]*>/i', '', $html);
if (function_exists('mb_chr')) {
$html = preg_replace_callback('/&#x([0-9a-fA-F]+);/', function ($m) {
$code = hexdec($m[1]);
if ($code < 0x80) return $m[0];
return mb_chr($code, 'UTF-8');
}, $html);
$html = preg_replace_callback('/&#(\d+);/', function ($m) {
$code = (int)$m[1];
if ($code < 0x80) return $m[0];
return mb_chr($code, 'UTF-8');
}, $html);
}
return trim($html);
}
private function processBodyMarkers($content)
{
return preg_replace_callback(
'/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/',
function ($m) {
return $this->wrapHtmlForTranslation($m[1]);
},
$content
);
}
private function wrapHtmlForTranslation($html)
{
if ($html === '' || $html === null) return '';
if (stripos($html, '<') === false) {
$html = trim($html);
if ($html === '' || !preg_match('/[\x{4e00}-\x{9fff}]/u', $html)) return $html;
return 'hmcm-' . $html . '-hmcms';
}
$originalHtml = $html;
$dom = $this->loadHtmlDocument($html);
$xpath = new DOMXPath($dom);
$skipTags = ['pre', 'code', 'script', 'style', 'textarea'];
$blockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td', 'th', 'dd', 'dt', 'caption'];
$textNodes = $xpath->query('//text()');
if ($textNodes === false) return $originalHtml;
foreach ($textNodes as $node) {
$skip = false;
$p = $node->parentNode;
while ($p && $p->nodeType === XML_ELEMENT_NODE) {
if (in_array(strtolower($p->nodeName), $skipTags, true)) {
$skip = true;
break;
}
$p = $p->parentNode;
}
if ($skip) continue;
$isInBlock = false;
$p = $node->parentNode;
while ($p && $p->nodeType === XML_ELEMENT_NODE) {
if (in_array(strtolower($p->nodeName), $blockTags, true)) {
$isInBlock = true;
break;
}
$p = $p->parentNode;
}
if (!$isInBlock) continue;
$rawText = $node->textContent;
$text = trim($rawText);
if ($text === '') continue;
if (mb_strlen($text, 'UTF-8') < 2) continue;
if (!preg_match('/[\x{4e00}-\x{9fff}]/u', $text)) continue;
if (strpos($text, 'hmcm-') !== false) continue;
$leading = '';
$trailing = '';
if (preg_match('/^(\s*)/u', $rawText, $m)) $leading = $m[1];
if (preg_match('/(\s*)$/u', $rawText, $m)) $trailing = $m[1];
$node->nodeValue = $leading . 'hmcm-' . $text . '-hmcms' . $trailing;
}
$result = $this->saveHtmlClean($dom);
if (trim($result) === '') return $originalHtml;
return $result;
}
private function isHtml($string)
{
return preg_match('/<[^>]+>/', $string) > 0;
}
private function translateHtmlBatch($htmlList, $targetLang)
{
if (empty($htmlList)) return [];
$this->L(0, 'HTML 片段翻譯(批量):' . count($htmlList) . ' 個片段');
$htmlDocs = [];
$allTexts = [];
foreach ($htmlList as $html) {
$dom = $this->loadHtmlDocument($html);
$xpath = new DOMXPath($dom);
$textNodes = $xpath->query('//text()');
$nodes = [];
foreach ($textNodes as $node) {
$text = trim($node->textContent);
if ($text === '' || mb_strlen($text, 'UTF-8') < 2) continue;
if (!preg_match('/[\x{4e00}-\x{9fff}]/u', $text)) continue;
$nodes[] = ['node' => $node, 'text' => $text];
$allTexts[$text] = true;
}
$htmlDocs[] = ['html' => $html, 'dom' => $dom, 'nodes' => $nodes];
}
$uniqueTexts = array_keys($allTexts);
$this->L(0, ' HTML 內文本去重後:' . count($uniqueTexts) . ' 條');
$translations = [];
if (!empty($uniqueTexts)) {
$translations = $this->translateBatch($uniqueTexts, $targetLang);
}
$result = [];
foreach ($htmlDocs as $docInfo) {
foreach ($docInfo['nodes'] as $item) {
$src = $item['text'];
if (isset($translations[$src])) {
$item['node']->nodeValue = $translations[$src];
}
}
$rendered = $this->saveHtmlClean($docInfo['dom']);
$result[$docInfo['html']] = (trim($rendered) !== '') ? $rendered : $docInfo['html'];
}
return $result;
}
// ============================================================
// 佔位符替換主入口
// ============================================================
public function replacePlaceholders($content, $targetLang = null)
{
if (self::isBotRequest()) {
$content = preg_replace('/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/', '$1', $content);
$content = preg_replace('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', '$1', $content);
return $content;
}
if ($targetLang === null) $targetLang = $this->currentLang;
$this->lastErrorCount = 0;
$this->googleFailureRecordedThisRequest = false;
$__t0 = microtime(true);
$this->L(0, '');
$this->L(0, '╔══════════════════════════════════════════╗');
$this->L(0, '║ 翻譯開始 ║');
$this->L(0, '╚══════════════════════════════════════════╝');
$this->L(0, '語言:' . $targetLang . '(' . date('Y-m-d H:i:s') . ')');
$this->L(0, '頁面大小:' . strlen($content) . ' 字節');
if ($targetLang === 'zh') {
$result = preg_replace('/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/', '$1', $content);
$result = preg_replace('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', '$1', $result);
$this->L(0, '簡體頁面:剝離佔位符');
$this->L(0, '總耗時:' . round((microtime(true) - $__t0) * 1000) . ' ms');
$this->L(0, '');
return $result;
}
if ($targetLang === 'cht') {
$content = preg_replace('/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/', '$1', $content);
$result = $this->processChinesePage($content, $targetLang);
$this->L(0, '繁體頁面:OpenCC 本地轉換');
$this->L(0, '總耗時:' . round((microtime(true) - $__t0) * 1000) . ' ms');
$this->L(0, '');
return $result;
}
$content = $this->processBodyMarkers($content);
preg_match_all('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', $content, $matches);
if (empty($matches[0])) {
$this->L(0, '無佔位符,直接返回');
$this->L(0, '');
return $content;
}
$uniqueTexts = [];
$htmlTexts = [];
foreach ($matches[1] as $original) {
$text = $this->normalizeText($original);
if ($text === '') continue;
if ($this->isHtml($text)) {
$htmlTexts[$text] = true;
} else {
$uniqueTexts[$text] = true;
}
}
$plainList = array_keys($uniqueTexts);
$htmlList = array_keys($htmlTexts);
$this->L(0, '佔位符總數:' . count($matches[0]));
$this->L(0, '去重後:純文本 ' . count($plainList) . ' 條,HTML 片段 ' . count($htmlList) . ' 條');
$translations = $this->getTexts($plainList, $targetLang);
$toTranslate = array();
foreach ($plainList as $text) {
if (!isset($translations[$text])) {
$toTranslate[] = $text;
}
}
$this->L(0, '緩存命中:' . count($translations) . ' 條');
$this->L(0, '需翻譯:' . count($toTranslate) . ' 條');
if (!empty($toTranslate)) {
$batchResult = $this->translateBatch($toTranslate, $targetLang);
foreach ($batchResult as $src => $dst) {
$translations[$src] = $dst;
}
}
if (!empty($htmlList)) {
$htmlTranslations = $this->translateHtmlBatch($htmlList, $targetLang);
foreach ($htmlTranslations as $src => $dst) {
$translations[$src] = $dst;
}
}
$result = preg_replace_callback(
'/\{\{hmcm-([\s\S]+?)-hmcms\}\}/',
function ($m) use ($translations) {
$original = trim($m[1]);
$key = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $original);
$key = str_replace(["\r\n", "\n", "\r"], ' ', $key);
$key = preg_replace('/\s+/u', ' ', $key);
$key = trim($key);
if ($key === '' || !isset($translations[$key])) return $m[0];
if ($translations[$key] === '' || $translations[$key] === null) return $m[0];
return $this->escapeTranslatedText($translations[$key]);
},
$content
);
$total = round((microtime(true) - $__t0) * 1000);
$residual = preg_match('/\{\{hmcm-/', $result) ? '⚠ 有殘留佔位符' : '✔ 全部替換完成';
$this->L(0, '');
$this->L(0, '╔══════════════════════════════════════════╗');
$this->L(0, '║ ' . $residual . ' 總耗時 ' . $total . ' ms ║');
$this->L(0, '║ 錯誤計數 ' . $this->lastErrorCount . ' ║');
$this->L(0, '╚══════════════════════════════════════════╝');
$this->L(0, '');
return $result;
}
// ============================================================
// 繁體頁面處理
// ============================================================
private function processChinesePage($content, $lang)
{
$content = preg_replace('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', '$1', $content);
if ($lang === 'zh') return $content;
if (!$this->converterAvailable) return $content;
return $this->convertWholePage($content, 'S2T');
}
private function convertWholePage($html, $strategy)
{
$parts = preg_split('/(<[^>]*>)/s', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === false) return OpenCC::convert($html, $strategy);
$result = '';
$count = count($parts);
for ($i = 0; $i < $count; $i++) {
$part = $parts[$i];
if ($i % 2 === 1) { $result .= $part; continue; }
if ($part === '' || !preg_match('/[\x{4e00}-\x{9fff}]/u', $part)) {
$result .= $part;
continue;
}
$result .= OpenCC::convert($part, $strategy);
}
return $result;
}
}
七、PHP 端關鍵配置
// 構造函數中
$this->googleProxyUrl = 'http://127.0.0.1:8000/translate';
// 關鍵常量(v5.5)
const MAX_BATCH_SIZE = 30; // 單次最多翻譯 30 條
const TOTAL_TIMEOUT = 2.0; // 本地翻譯總超時(秒)
const CHUNK_SIZE = 10; // 分塊大小
const PROXY_TIMEOUT = 2; // 默認單次 cURL 超時
const PROXY_CONNECT_TIMEOUT = 1; // 連接超時
八、驗證清單
| 檢查項 | 命令 | 預期 |
|---|---|---|
| Python 服務 | supervisorctl status |
RUNNING |
| 端口監聽 | ss -tlnp | grep 8000 |
LISTEN |
| 單次翻譯 | curl -X POST ... |
< 0.3 秒 |
| 中文頁面 | 訪問首頁 | 0.1-0.3 秒 |
| 日語首次 | 訪問 /jp/ 頁面 | 1-3 秒 |
| 日語二次 | 再次訪問 | 0.1-0.5 秒 |
| 內存佔用 | free -h |
available > 500M |
九、避坑記錄
| 問題 | 原因 | 解決 |
|---|---|---|
| 清華鏡像 403 | 臨時封禁 | 換北大鏡像 |
| conda 命令找不到 | 中斷了 init | 手動 conda init bash |
| 服務卡 25 秒 | 北美代理超時 | 移除香港代理邏輯 |
| 頁面卡 502 | 單次翻譯太多 | 限 30 條 + 2 秒超時 |
| cURL 卡 16 秒 | 重試 2 次×8 秒 | 去重試,動態超時 |
| 內存不足 | 裝了多餘插件 | 卸 PHP 7.4、Docker、郵局 |
十、性能數據
| 場景 | 耗時 |
|---|---|
| 30 條 iciba 併發 | 0.3-0.5 秒 |
| 單條翻譯 | 0.05-0.1 秒 |
| 全緩存命中 | 0.05 秒 |
| 首屏(含翻譯 30 條) | 0.8-2 秒 |
| 二次訪問 | 0.1-0.5 秒 |
十一、漸進式翻譯機制
頁面有 139 條待翻譯時:
| 訪問次數 | 緩存 | 翻譯 | 累計 |
|---|---|---|---|
| 第 1 次 | 42 | 30 | 72 |
| 第 2 次 | 72 | 30 | 102 |
| 第 3 次 | 102 | 30 | 132 |
| 第 4 次 | 132 | 7 | 139 ✅ |
Googlebot 一天爬幾十次,1-2 天全站翻譯完成。