红穆笔记
首頁 程序筆記 翻譯系統完整部署文檔
程序筆記 PHP 備忘筆記 實用收藏 python

翻譯系統完整部署文檔

翻译系统完整部署文档

翻譯系統完整部署文檔

一、架構說明

用戶 → 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 .= '&amp;';
                $i++;
                continue;
            }
            if ($c === '<')  { $result .= '&lt;';   $i++; continue; }
            if ($c === '>')  { $result .= '&gt;';   $i++; continue; }
            if ($c === '"')  { $result .= '&quot;'; $i++; continue; }
            if ($c === "'")  { $result .= '&#39;';  $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 天全站翻譯完成。

微信赞赏

微信

支付宝赞赏

支付寶

✍️ 作者: 紅穆

網站管理員 · 感謝閱讀,更多精彩內容敬請關注

作者主頁 查看主頁 →

相關文章

php问卷调查系统

php問卷調查系統 程序筆記 PHP 業餘愛好 實用收藏

問卷調查系統 · 完整教程一、系統架構數據流用戶填寫問卷 (index.php) │ │ POST FormData ▼ .api.php ←── 接收 + 記錄環境信息(IP/OS/瀏覽器/設備/時間) │ │ 寫入 JSON ▼ data/submissions.json │ │ 讀取 ▼ ad…
👁 35
帝国CMS 多少数据分表好?已发布的数据如何进行分表操作?帝国cms百万数据如何平均分配到分表?

帝國CMS 多少數據分表好?已發佈的數據如何進行分表操作?帝國cms百萬數據如何平均分配到分表? 程序筆記 帝國cms 實用收藏 帝國插件

帝國CMS 多少數據分表好?1、數據庫50G,應新建主表;2、滿5W條數據新建副表,並將最新建的副表設爲當前存放表;【也有建議10W條數據分一次表的】數據量過大,已導致MYSQL數據對IO的操作讀寫量巨大,導致整個服務器負載過大。特別是帝國cms後臺操作緩慢,尤其是數據量大的欄目,本人的站點沒分表前…
👁 692
sqlite数据库在线php读取操作查看,pbootcms数据库在线查看

sqlite數據庫在線php讀取操作查看,pbootcms數據庫在線查看 程序筆記 PbootCms 語言筆記 PHP

在使用pbootcms的時候,很多時候會用到數據庫,但是pb的數據庫是.db文件,所以就很麻煩。沒有現成的可視化頁面可以查看數據庫,因此,這裏開發了一個php在線查看的php可視化頁面。這樣會方便許多。如何使用?在db文件的文件夾中,創建一個php,將下面代碼放進去,修改自己的數據庫名稱,然後訪問p…
👁 179
phpstudy 8.2扩展修复

phpstudy 8.2擴展修復 程序筆記 PHP 業餘愛好

不知道php抽什麼風。 8.2版本的php,打開後,擴展安裝不上,打開php.ini配置,啥都沒有。 所以,找來一段內容,填進去,問題就解決了。 [PHP] engine = On short_open_tag = On precision = 14 output_buffering = 4096 …
👁 36

推薦閱讀

PHP 查找是否存在指定特征的目录,没有则创建

PHP 查找是否存在指定特徵的目錄,沒有則創建 語言筆記 PHP

PHP 檢查指定目錄是否存在前綴admin-的目錄,如果存在便返回目錄名稱,不存在則創建一個目錄,幫我寫成函數,其中admin-是第一個變量,第二個是創建的新目錄名稱,其中,admin-是變量以下是一個實現此功能的 PHP 函數。該函數首先檢查指定目錄中是否存在以 "admin-" 開頭的目錄,如果…
👁 172
PHP获取文件夹文件的代码有什么

PHP獲取文件夾文件的代碼有什麼 語言筆記 PHP

要使用PHP獲取文件夾下的所有文件,您可以使用glob()函數或scandir()函數。下面是兩種方法的示例代碼:glob()函數glob()函數接受一個參數並返回一個數組,其中包含滿足指定規則的所有文件和目錄。在獲取目錄中的所有文件時,您可以使用以下代碼:$files = glob('/path/…
👁 193
音乐人必备:最全的乐器音源音色中英文对照表!

音樂人必備:最全的樂器音源音色中英文對照表! 踩坑總結 業餘愛好 編曲學習

大家都知道,現在市面上很多先進教材、音源插件幾乎都是英文版的,每次看到不懂的單詞總是要去查找翻譯,十分麻煩,今天教程君爲大家羅列了一份很全面的樂器中英文對照表,再也不用辛苦翻譯了,趕快來學習吧!Woodwinds:木管樂器1.Piccolo 短笛2.Flute 長笛3.Soprano Recorde…
👁 928
(自适应手机端)pbootcms响应式食品零食连锁加盟店网站模板 日化用品网站源码下载 0218

(自適應手機端)pbootcms響應式食品零食連鎖加盟店網站模板 日化用品網站源碼下載 0218 實用收藏 pbootcms模板

一款自適應手機端的PbootCMS響應式食品零食連鎖加盟店網站模板。設計風格活潑食慾,適合展示零食產品、連鎖加盟政策及門店形象。有助於食品品牌在線上吸引加盟商,拓展市場渠道。模板展示 安裝說明 網站後臺:/admin.php 賬號:admin 密碼:admin 解壓密碼:www.4s5.cn相關文章…
👁 38