Hongmu Notes
Home Program Notes Translation System Comprehensive Deployment Documentation
Program Notes PHP Memo notes Practical Collection python

Translation System Comprehensive Deployment Documentation

Translation System Comprehensive Deployment Documentation

Translation System Comprehensive Deployment Documentation

I. Architecture Overview

用户 → PHP → 本地 Python 服务(127.0.0.1:8000)
              │
              ├─ 阶段1:词霸(iciba)并发,限 30 条,总超时 2 秒
              ├─ 阶段2:百度官方 API 兜底
              └─ 阶段3:返回原文

characteristic

  • Domestic only; no cross-border transactions.
  • PowerWord – Free
  • Hierarchical degradation
  • Single-limit: 30 entries – prevents the page from freezing.
  • Reply within 30 seconds for up to 30 messages; for more than 30 messages, continue after the next page load.

II. Python Environment Installation

# ============================================================
# 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

III. Python Service File

way/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"}

IV. Supervisor Guardian Configuration

# ============================================================
# 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":"今天天气不错"}'

V. Common Management Commands

# 查看状态
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

VI. PHP Translation Tools

<?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;
    }
}


7. PHP Server-Side Key Configuration

// 构造函数中
$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;     // 连接超时

VIII. Verification Checklist

Check Item order expect
Python Service supervisorctl status RUNNING
Port Monitoring ss -tlnp | grep 8000 LISTEN
Single translation curl -X POST ... <0.3 seconds
Chinese page Visit Homepage 0.1–0.3 seconds
Japanese – First time Visit the /jp/ page 1–3 seconds
Japanese (Second Version) Visit again 0.1–0.5 seconds
Memory usage free -h available > 500M

IX. Pitfall Avoidance Record

question cause solve
Tsinghua Mirror 403 Temporary ban Change to Peking University Mirror
The `conda` command could not be found. Interrupted init hand movement conda init bash
Service Card – 25 seconds North American proxy timeout Remove Hong Kong proxy logic
Page Error 502 Too many translations in a single operation Maximum 30 entries + 2-second timeout
cURL: 16 seconds Retry 2 times × 8 seconds Duplicate retry, dynamic timeout
run out of memory Installed unnecessary plugins Uninstall PHP 7.4, Docker, and Post Office

X. Performance Data

scene time consuming
30 ICIBA concurrent events 0.3–0.5 seconds
Single translation 0.05–0.1 seconds
Full cache hit 0.05 seconds
First screen (including 30 translations) 0.8–2 seconds
Secondary visit 0.1–0.5 seconds

XI. Progressive Translation Mechanism

When there are 139 items awaiting translation on the page:

Page views cache translate accumulative total
First time 42 30 72
Second time 72 30 102
3rd time 102 30 132
4th time 132 7 139 ✅

Googlebot crawls the site dozens of times per day; the full site translation is completed within 1–2 days.

微信赞赏

WeChat

支付宝赞赏

Alipay

✍️ Author: Hong Mu

webmaster · Thanks for reading, stay tuned for more exciting content

Author homepage View home page →

Related articles

What is the optimal number of data sub-tables for EmpireCMS? How should published data be distributed across sub-tables? How can a million records in EmpireCMS be evenly distributed across multiple sub-tables?

What is the optimal number of data sub-tables for EmpireCMS? How should published data be distributed across sub-tables? How can a million records in EmpireCMS be evenly distributed across multiple sub-tables? Program Notes Empire cms Practical Collection empire plugin

How many data rows should be split into separate tables for EmpireCMS? 1. For a database size of 50 GB, it is advisable to create a new main table; 2. For a dataset of 50,000 rows or more, create a new secondary table and set this newly created secondary table as the current storage table; [Some recommend splitting the entire dataset into a single table when the data volume reaches 100,000 rows.] An excessively large dataset has resulted in extremely high I/O read/write operations on the MySQL database, leading to excessive server load. This is particularly noticeable when performing backend operations in EmpireCMS – especially for sections with large data volumes; this was the case on my website before I implemented table partitioning...
👁 692
EmpireCMS uses PHP to directly submit data for updating articles.

EmpireCMS uses PHP to directly submit data for updating articles. Program Notes Empire cms Language Notes PHP

Submitted code: While having some free time, I took the time to thoroughly study the Train Engine module and integrate it with Empire CMS, resulting in the following code. Experienced PHP developers are welcome to modify this code; however, this is merely a simple submission script – the data must be configured manually! <?php $url = "http://www.4s5.cn/e/admin/123.php"; // Replace this with the actual URL where the data should be received...
👁 390
sqlite database online php read operation view, pbootcms database online view

sqlite database online php read operation view, pbootcms database online view Program Notes PbootCms Language Notes PHP

When using pbootcms, a database is often used, but the database of pb is a .db file, so it is very troublesome. There is no ready-made visualization page to view the database, so a php visualization page for php online viewing is developed here. This will be much more convenient. How to use? In the folder of the db file, create a php, put the following code in it, modify your database name, and then access p...
👁 179
PHPStudy 8.2 Extension Fix

PHPStudy 8.2 Extension Fix Program Notes PHP Hobbies

I don't know what's going on with PHP – it's acting strangely. With PHP version 8.2, when I tried to enable the extensions, they wouldn't install; checking the `php.ini` configuration showed nothing there. So, I found a snippet of code and inserted it into the `php.ini` file – and the problem was solved! [PHP] engine = On short_open_tag = On precision = 14 output_buffering = 4096...
👁 36

Recommended reading

PHP: Check whether a directory with a specified characteristic exists; if not, create it.

PHP: Check whether a directory with a specified characteristic exists; if not, create it. Language Notes PHP

Write a PHP function that checks whether a specified directory contains a subdirectory with the prefix "admin-"; if such a directory exists, return its name; otherwise, create a new directory. The first parameter should be "admin-", and the second parameter should be the name of the new directory to be created. Here is a PHP function that implements this functionality: The function first checks whether the specified directory contains any subdirectory that starts with ""admin-""; if...
👁 172
What is the code to retrieve files from a folder in PHP?

What is the code to retrieve files from a folder in PHP? Language Notes PHP

To retrieve all files within a folder using PHP, you can use the `glob()` or `scandir()` functions. Below are example codes for both methods: `glob()` function: The `glob()` function accepts a single parameter and returns an array containing all files and directories that match the specified pattern. To retrieve all files in a directory, you can use the following code: `$files = glob('/path/...');`
👁 193
Must-have for musicians: The most comprehensive instrument sound source timbre reference table – with both Chinese and English versions!

Must-have for musicians: The most comprehensive instrument sound source timbre reference table – with both Chinese and English versions! Summary of pitfalls Hobbies Arrangement learning

As everyone knows, most advanced music textbooks and audio plugin software available on the market are currently available in English; whenever you come across an unfamiliar word, you often have to look up its translation – which can be quite cumbersome. Today, we've compiled a comprehensive bilingual instrument list (Chinese–English) for you – so you no longer need to spend time translating! Dive in and start learning today! Woodwinds: Woodwind instruments – 1. Piccolo – Short flute; 2. Flute – Long flute; 3. Soprano recorder...
👁 928
(Adaptive Mobile Version) pbootCMS responsive website template for food and snack chain franchise stores; Download source code for a daily chemical products website – 0218

(Adaptive Mobile Version) pbootCMS responsive website template for food and snack chain franchise stores; Download source code for a daily chemical products website – 0218 Practical Collection pbootcms Template

An adaptive, mobile-friendly PbootCMS responsive website template designed for food and snack chain franchise stores. Its vibrant and appealing design is ideal for showcasing snack products, franchise policies, and store branding. This template helps food brands attract franchisees online and expand their market reach. Template Preview | Installation Instructions | Website Backend: /admin.php | Username: admin | Password: admin | Extraction Password: www.4s5.cn | Related Articles...
👁 38