홍무 노트
홈페이지 프로그램 노트 번역 시스템 전체 배포 문서
프로그램 노트 PHP 기록 노트 실용적인 저장하기 python

번역 시스템 전체 배포 문서

번역 시스템 전체 배포 문서

번역 시스템 전체 배포 문서

1. 아키텍처 설명

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

특징

  • 전국적으로, 국경을 넘는 거래는 포함되지 않음.
  • Ciba 무료 이용
  • 단계적 하향 조정
  • 1회당 최대 30개까지 제한하여 페이지가 멈추는 것을 방지합니다.
  • 30개의 메시지에 대해 1초 이내에 응답; 30개를 초과할 경우 다음에 페이지 새로고침 후 계속합니다.

2. 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

III. 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"}

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

V. 흔히 사용되는 관리 명령어

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

6. 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;
    }
}


7. 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;     // 连接超时

8. 검증 목록

검사 항목 명령어 예상
Python 서비스 supervisorctl status RUNNING
포트 리스닝 ss -tlnp | grep 8000 LISTEN
단일 번역 curl -X POST ... < 0.3초
중국어 페이지 홈페이지로 이동 0.1–0.3초
일본어 처음 /jp/ 페이지를 방문하세요. 1–3초
일본어(2차) 다시 방문하기 0.1–0.5초
메모리 사용량 free -h available > 500M

IX. 함정 피하기 기록

문제 사유 해결
칭화 미러 403 임시 차단 베이징 대학교 이미징 대체
`conda` 명령어를 찾을 수 없습니다. init 작업 중단 수동 conda init bash
서비스 카드: 25초 북미 대리점 타임아웃 홍콩 대리 로직 제거
페이지 정지(502) 단일 번역 횟수가 너무 많습니다. 최대 30개 항목 + 2초 타임아웃
cURL: 16초 2회 재시도 (× 8초) 중복 처리 후 재시도 및 동적 타임아웃
메모리 부족 과도한 플러그인 설치됨 PHP 7.4, Docker, 우편 서비스 제거

10. 성능 데이터

사enario 소요 시간
30건의 iciba 병발 0.3–0.5초
단일 번역 0.05–0.1초
전체 캐시 충돌 0.05초
첫 번째 화면(30개의 번역 문항 포함) 0.8–2초
二次 방문 0.1–0.5초

11. 점진적 번역 메커니즘

페이지에 번역이 필요한 항목이 139개 있습니다:

방문 횟수 캐시 번역 누적
1차 42 30 72
제2회 72 30 102
3회 102 30 132
제4회 132 7 139 ✅

Googlebot은 하루에 수십 차례 crawling을 수행하며, 사이트 전체 번역 작업은 1~2일 만에 완료됩니다.

微信赞赏

위챗

支付宝赞赏

알리페이

✍️ 저자: 홍무

웹사이트 관리자 · 읽으셔서 감사합니다. 더 많은 흥미로운 콘텐츠를 확인하시려면 계속 방문해 주세요.

저자 프로필 홈페이지 보기 →

관련 기사

EmpireCMS에서 데이터를 몇 개의 분할 테이블로 분할하는 것이 적절한가요? 이미 게시된 데이터는 어떻게 분할 테이블로 분할해야 하나요? EmpireCMS의 100만 건의 데이터를 여러 분할 테이블에 어떻게 균등하게 분배해야 하나요?

EmpireCMS에서 데이터를 몇 개의 분할 테이블로 분할하는 것이 적절한가요? 이미 게시된 데이터는 어떻게 분할 테이블로 분할해야 하나요? EmpireCMS의 100만 건의 데이터를 여러 분할 테이블에 어떻게 균등하게 분배해야 하나요? 프로그램 노트 Empire CMS 실용적인 저장하기 Imperial 플러그인

EmpireCMS에서 데이터를 몇 개의 별도 테이블로 분할하는 것이 적절한가요? 1. 데이터베이스 용량이 50GB라면 주 테이블을 새로 생성하는 것이 좋습니다; 2. 데이터가 5만 건에 달하는 경우 부 테이블을 새로 생성하고, 새로 생성한 부 테이블을 현재 저장 테이블로 설정하는 것이 좋습니다. [다른 의견으로는 데이터가 10만 건에 달할 경우 한 번에 하나의 테이블로 분할하는 방법도 제안됩니다.] 데이터량이 너무 많아져 MySQL의 데이터 I/O 작업량이 매우 커져서 전체 서버 부하가 과도해지는 문제가 발생합니다. 특히 EmpireCMS의 백엔드 작업이 느려지는 현상이 두드러지는데, 이는 특히 데이터량이 많은 칼럼의 경우에 해당합니다. 본인의 사이트는 데이터 분할을 하지 않은 상태였습니다…
👁 692
EmpireCMS는 PHP를 사용하여 데이터를 직접 전송하여 게시물을 업데이트합니다.

EmpireCMS는 PHP를 사용하여 데이터를 직접 전송하여 게시물을 업데이트합니다. 프로그램 노트 Empire CMS 언어 노트 PHP

제출된 코드: 여유 시간이 생긴 틈을 이용해 'Trainhead' 모듈을 꼼꼼히 분석한 뒤 이를 EmpireCMS와 결합하여 아래와 같은 코드를 작성했습니다. PHP 숙련자라면 이 코드를 수정할 수 있습니다. 이 코드는 단순한 제출용 코드이며, 데이터는 직접 설정해야 합니다! <?php $url = "http://www.4s5.cn/e/admin/123.php"; // 실제 데이터 수신 URL로 변경하세요…
👁 390
SQLite 데이터베이스를 PHP를 사용하여 온라인으로 읽고 조회하기, PBootCMS 데이터베이스를 온라인으로 확인하기

SQLite 데이터베이스를 PHP를 사용하여 온라인으로 읽고 조회하기, PBootCMS 데이터베이스를 온라인으로 확인하기 프로그램 노트 PbootCms 언어 노트 PHP

pbootcms를 사용할 때는 종종 데이터베이스를 활용해야 하는데, PB의 데이터베이스는 .db 파일 형태이므로 다소 번거로운 점이 있습니다. 데이터베이스를 확인할 수 있는 일괄 시각화 페이지가 제공되지 않아, 이에 따라 PHP 기반의 온라인 데이터베이스 확인용 시각화 페이지를 개발하였습니다. 이를 통해 작업이 훨씬 편리해집니다. 어떻게 사용하나요? .db 파일이 있는 폴더 내에 PHP 파일을 생성한 후 아래 코드를 작성하고, 사용자의 데이터베이스 이름을 수정한 다음 p…를 접속하세요.
👁 179
Empire CMS 8.0의 검색 목록 페이지에서 동적 PHP 코드 표시 기능을 지원합니다.

Empire CMS 8.0의 검색 목록 페이지에서 동적 PHP 코드 표시 기능을 지원합니다. 프로그램 노트 Empire CMS PHP 사고 사례 요약

EmpireCMS를 8.0 버전으로 업데이트했습니다. 일상적인 사용 과정에서 검색 목록 페이지가 동적 PHP 코드 표시를 지원하지 않는다는 점을 발견하였으므로, 이 코드를 수정하여 여기에 삽입하였습니다. <?php require("../../class/connect.php"); require("../../data/dbcache/class.php"); …
👁 101
phpstudy 8.2 확장 기능 복구

phpstudy 8.2 확장 기능 복구 프로그램 노트 PHP 아마추어 취미

PHP에서 왠지 모르게 이상한 현상이 발생하는 것 같습니다. PHP 8.2 버전을 실행했을 때 확장 프로그램이 설치되지 않았습니다. php.ini 설정 파일을 확인해 보니 아무 내용도 없었습니다. 이에 따라 특정 코드를 찾아 넣어보니 문제는 해결되었습니다. [PHP] engine = On short_open_tag = On precision = 14 output_buffering = 4096 …
👁 36

추천 읽을거리

jQuery를 사용하여 지정된 텍스트를 복사하거나, 함수를 포괄하여 여러 차례 호출할 수 있습니다.

jQuery를 사용하여 지정된 텍스트를 복사하거나, 함수를 포괄하여 여러 차례 호출할 수 있습니다. 언어 노트 JavaScript

https://www.4s5.cn/archives/903.html 이 문서에서 저는 '클릭하여 복사' 기능을 상세히 설명했지만, 이후 제 요구사항이 변경되었습니다. 이제 여러 번 클릭이 필요하며, 각 클릭 대상 요소도 서로 다를 수 있습니다. 따라서 함수를 래핑해야 합니다. 래핑된 코드는 다음과 같습니다: function copyToClipboard(btnId, inputId) { $( '#…
👁 177
PHP는 지정된 특성을 가진 디렉토리가 존재하는지 확인하고, 존재하지 않을 경우 이를 생성합니다.

PHP는 지정된 특성을 가진 디렉토리가 존재하는지 확인하고, 존재하지 않을 경우 이를 생성합니다. 언어 노트 PHP

PHP를 사용하여 지정된 디렉터리에 'admin-'라는 전제를 가진 디렉터리가 존재하는지 확인합니다. 존재하는 경우 해당 디렉터리 이름을 반환하고, 존재하지 않는 경우 새로운 디렉터리를 생성합니다. 이 기능을 함수로 구현하십시오. 여기서 'admin-'는 첫 번째 변수이며, 두 번째 변수는 새로 생성되는 디렉터리 이름입니다. 다음은 이 기능을 구현한 PHP 함수입니다. 이 함수는 먼저 지정된 디렉터리 내에 '"admin-"'로 시작하는 디렉터리가 있는지 확인합니다. 만약…
👁 172
PHP를 사용하여 폴더 내 파일을 가져오는 코드는 무엇인가요?

PHP를 사용하여 폴더 내 파일을 가져오는 코드는 무엇인가요? 언어 노트 PHP

PHP를 사용하여 폴더 내의 모든 파일을 가져오려면 `glob()` 함수 또는 `scandir()` 함수를 사용할 수 있습니다. 다음은 이 두 가지 방법에 대한 예시 코드입니다: `glob()` 함수: `glob()` 함수는 하나의 매개변수를 받아 지정된 규칙을 만족하는 모든 파일 및 디렉터리를 포함하는 배열을 반환합니다. 디렉터리 내의 모든 파일을 가져오려면 다음 코드를 사용할 수 있습니다: `$files = glob('/path/…');`
👁 193
PHP를 사용하여 데이터베이스에 연결하고, SQL 문 실행 여부를 판단합니다.

PHP를 사용하여 데이터베이스에 연결하고, SQL 문 실행 여부를 판단합니다. 언어 노트 PHP

아마도 제가 점점 더 게으르게 되어버렸을 거예요. 코드를 남겨두면 다음에 사용할 때 그냥 복사하면 되는 것 같네요? <br/>`<br/>// 데이터베이스 연결 매개변수 설정<br/>$servername = "localhost"; // 서버 이름<br/>$username = "your_username"; // 사용자 이름<br/>$password = "your_password"; // 비밀번호…<br/>`
👁 110
음악가에게 필수적인 자료: 가장 포괄적인 악기 음원 및 음색 정보표(중국어 및 영어 대조표)!

음악가에게 필수적인 자료: 가장 포괄적인 악기 음원 및 음색 정보표(중국어 및 영어 대조표)! 사고 사례 요약 아마추어 취미 악기 편곡 학습

대부분의 분들께서는 현재 시장에 출시된 많은 첨단 교재나 음향 플러그인들이 거의 모두 영문판이라는 것을 알고 계실 것입니다. 그런데 모르는 단어를 보는 순간마다 번역을 찾아봐야 하는 번거로움이 있습니다. 오늘은 여러분을 위해 매우 포괄적인 악기 중 영어-중국어 대조표를 준비했습니다. 이제 더 이상 번역을 해야 하는 번거로움 없이 바로 학습해 보세요! Woodwinds(목관악기): 1. Piccolo(단조 피콜로) 2. Flute(장피리) 3. Soprano(소프라노) Recorde…
👁 928
(자동 적응형 모바일용) pbootcms 리스폰시브 식품 및 간식 체인 프랜차이즈 매장 웹사이트 템플릿; 일용화학품 관련 웹사이트 소스 코드 다운로드 – 0218

(자동 적응형 모바일용) pbootcms 리스폰시브 식품 및 간식 체인 프랜차이즈 매장 웹사이트 템플릿; 일용화학품 관련 웹사이트 소스 코드 다운로드 – 0218 실용적인 저장하기 pbootcms 템플릿

자체 적응형 모바일용 PbootCMS 리스폰시브 식품 및 스낵 체인 프랜차이즈 매장 웹사이트 템플릿입니다. 생동감 있고 매력적인 디자인 스타일로, 스낵 제품, 체인 프랜차이즈 정책 및 매장 이미지를 효과적으로 전시하기에 적합합니다. 이 템플릿을 활용하면 식품 브랜드가 온라인으로 프랜차이즈 가맹점을 유치하고 시장 채널을 확장하는 데 도움이 됩니다. 템플릿 확인 및 설치 안내: 웹사이트 백엔드: /admin.php, 사용자 이름: admin, 비밀번호: admin, 압축 해제 비밀번호: www.4s5.cn | 관련 기사…
👁 38