紅穆ノート
レコメンド プログラムノート 翻訳システムの完全導入ドキュメント
プログラムノート PHP メモ 実用収集 python

翻訳システムの完全導入ドキュメント

翻訳システムの完全導入ドキュメント

翻訳システムの完全導入ドキュメント

一、アーキテクチャ説明

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

特徴

  • 国内のみ、国境を越えた取引は対象外です。
  • 詞霸(無料)
  • 段階的降格
  • 1回の処理上限を30件に設定し、ページの処理停止を防止します。
  • 30件以内で即時返信。30件を超えた場合は、次回更新時に自動継続します。

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

四、Supervisor ガード設定

# ============================================================
# 1. 安装 supervisor(国内服务器通常没装)
# ============================================================
yum install -y supervisor
systemctl enable supervisord
systemctl start supervisord

# ============================================================
# 2. 创建配置文件
# ============================================================
cat > /etc/supervisord.d/translation_local.ini << 'EOF'
[program:translation_local]
command=/root/miniconda3/envs/translate/bin/uvicorn translation_service:app --host 127.0.0.1 --port 8000 --workers 2
directory=/www/wwwroot/translation_local
autostart=true
autorestart=true
startretries=3
user=root
redirect_stderr=true
stdout_logfile=/www/wwwroot/translation_local/service.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
environment=PATH="/root/miniconda3/envs/translate/bin:%(ENV_PATH)s"
EOF

# ============================================================
# 3. 启动服务
# ============================================================
supervisorctl reread
supervisorctl update
supervisorctl status

# ============================================================
# 4. 测试
# ============================================================
curl -s -X POST http://127.0.0.1:8000/translate \
  -H "Content-Type: application/json" \
  -d '{"sl":"zh-CN","tl":"ja","q":"今天天气不错"}'

五、常用管理コマンド

# 查看状态
supervisorctl status

# 重启
supervisorctl restart translation_local

# 停止
supervisorctl stop translation_local

# 启动
supervisorctl start translation_local

# 实时看日志
tail -f /www/wwwroot/translation_local/service.log

# 内存占用
free -h

# 端口监听
ss -tlnp | grep 8000

六、PHP翻訳関連機能

<?php
if (!defined('A6_ENTRY')) { http_response_code(403); exit('Forbidden'); }

/**
 * 多引擎翻译器 v5.5
 *
 * v5.5 变更(相对 v5.4.1):
 *   - 动态超时控制:cURL 超时 = 剩余可用时间,不再死等 8 秒
 *   - 单次翻译限制 30 条(maxBatchSize)
 *   - 总超时 2 秒,超时立即停止本地翻译
 *   - 去掉 cURL 重试,一次超时立即返回
 *   - 百度兜底改为 translateWithBaidu 方法
 *
 * 工作流:
 *   1. 缓存命中
 *   2. 本地词霸(限 30 条 + 总超时 2 秒)
 *   3. 百度兜底
 *   4. 返回原文
 */

$__autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($__autoload)) {
    require_once $__autoload;
}

use Overtrue\PHPOpenCC\OpenCC;

class BaiduTranslator
{
    private static $failureCacheTtl = 10;
    private static $failureCacheTtlLoaded = false;

    private $appId;
    private $secretKey;
    private $baiduApiUrl;
    private $googleProxyUrl;
    private $supportedLangs;
    private $currentLang;
    private $cacheTtl;
    private $debug;

    private $cacheSys;
    private $memo = array();

    private $engineChain = ['google', 'baidu'];

    private $googleMaxChars = 5000;
    private $baiduMaxChars  = 5500;

    private $googleBatchSeparator = "\n<<<__A6_SEP_9x7k2p__>>>\n";

    private $googleSourceLang = 'zh-CN';
    private $baiduSourceLang  = 'zh';

    private $qps = 10;
    private $lastRequestTime = 0.0;

    private $lastErrorCount = 0;
    private $googleFailureRecordedThisRequest = false;

    private $converterAvailable = false;

    // ============================================================
    //  ★ v5.5:超时控制参数
    // ============================================================
    const MAX_BATCH_SIZE         = 30;    // 单次最多翻译条数
    const TOTAL_TIMEOUT          = 2.0;   // 本地翻译总超时(秒)
    const CHUNK_SIZE             = 10;    // 分块大小(条)
    const PROXY_TIMEOUT          = 2;     // 默认单次 cURL 超时(秒)
    const PROXY_CONNECT_TIMEOUT  = 1;     // 连接超时(秒)

    // ============================================================
    //  其他常量
    // ============================================================
    const BAIDU_DISABLED_KEY         = 'baidu_disabled_until';
    const GOOGLE_BREAKER_KEY         = 'google_breaker_state';
    const BAIDU_INSUFFICIENT_BALANCE = '54004';
    const BAIDU_SERVICE_CLOSE        = '58002';
    const GOOGLE_FAIL_THRESHOLD      = 50;
    const GOOGLE_COOLDOWN_SECONDS    = 300;
    const JAKO_SHORT_WORD_MAX_LEN    = 4;

    const BOT_UA_PATTERN = '/Googlebot|bingbot|Baiduspider|YandexBot|DuckDuckBot|AhrefsBot|SemrushBot|MJ12bot|DotBot|PetalBot|Bytespider/i';

    public static function isBotRequest()
    {
        if (defined('SKIP_TRANSLATION') && SKIP_TRANSLATION) {
            return true;
        }
        $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
        return (bool)preg_match(self::BOT_UA_PATTERN, $ua);
    }

    private static function loadFailureCacheTtl()
    {
        if (self::$failureCacheTtlLoaded) return;
        self::$failureCacheTtlLoaded = true;
        if (defined('TRANSLATE_FAILURE_CACHE_TTL')) {
            $v = (int)TRANSLATE_FAILURE_CACHE_TTL;
            if ($v > 0) self::$failureCacheTtl = $v;
        }
    }

    public static function setFailureCacheTtl($seconds)
    {
        self::$failureCacheTtl = max(1, (int)$seconds);
    }

    public static function getFailureCacheTtl()
    {
        return self::$failureCacheTtl;
    }

    public function __construct($config = [])
    {
        self::loadFailureCacheTtl();

        $this->appId          = $config['app_id'] ?? '';
        $this->secretKey      = $config['secret_key'] ?? '';
        $this->baiduApiUrl    = $config['baidu_api_url'] ?? 'https://fanyi-api.baidu.com/api/trans/vip/translate';
        $this->googleProxyUrl = $config['google_proxy_url'] ?? 'http://127.0.0.1:8000/translate';
        $this->supportedLangs = $config['supported_langs'] ?? [
            'zh', 'en', 'jp', 'kor', 'fra', 'spa', 'th', 'ara', 'ru', 'pt',
            'de', 'it', 'el', 'nl', 'pl', 'bul', 'est', 'dan', 'fin', 'cs',
            'rom', 'slo', 'swe', 'hu', 'vie', 'cht'
        ];
        $this->currentLang    = $config['default_lang'] ?? 'zh';
        $this->cacheTtl       = $config['cache_ttl'] ?? 2592000;

        $this->debug = defined('DEBUG_TRANSLATION')
            ? DEBUG_TRANSLATION
            : ($config['debug'] ?? false);

        if (isset($config['engine_chain']) && is_array($config['engine_chain'])) {
            $this->engineChain = $config['engine_chain'];
        }
        if (isset($config['google_max_chars'])) {
            $this->googleMaxChars = (int)$config['google_max_chars'];
        }
        if (isset($config['baidu_max_chars'])) {
            $this->baiduMaxChars = (int)$config['baidu_max_chars'];
        }
        if (isset($config['qps'])) {
            $this->qps = (int)$config['qps'];
        }

        $this->cacheSys = get_cache_instance('Mran', 1, 'sys');
        $this->converterAvailable = class_exists('Overtrue\\PHPOpenCC\\OpenCC');
    }

    // ============================================================
    //  缓存读写
    // ============================================================
    private function getCache($lang)
    {
        return get_cache_instance('Mran', 1, $lang);
    }

    private function getText($text, $targetLang)
    {
        if ($text === '') return false;
        $cacheNew = $this->getCache($targetLang);
        $result = $cacheNew->get(md5($text));
        if ($result === '' || $result === null || $result === false) return false;
        return $result;
    }

    private function getTexts($texts, $targetLang)
    {
        if (empty($texts)) return array();
        $cacheNew = $this->getCache($targetLang);
        $result = array();
        $textByMd5 = array();
        $newKeys = array();
        foreach ($texts as $text) {
            $n = md5($text);
            $textByMd5[$n] = $text;
            $newKeys[] = $n;
        }
        $cached = $cacheNew->getMulti($newKeys);
        foreach ($cached as $n => $translated) {
            if ($translated === '' || $translated === null) continue;
            if (isset($textByMd5[$n])) {
                $result[$textByMd5[$n]] = $translated;
            }
        }
        return $result;
    }

    // ============================================================
    //  公开 API
    // ============================================================
    public function setCurrentLang($lang)
    {
        if (in_array($lang, $this->supportedLangs)) {
            $this->currentLang = $lang;
        }
        return $this;
    }

    public function getCurrentLang() { return $this->currentLang; }
    public function getLastErrorCount() { return $this->lastErrorCount; }

    // ============================================================
    //  翻译校验
    // ============================================================
    private function decideCacheAction($source, $translated, $targetLang = 'en')
    {
        if ($translated === '' || $translated === null) {
            return ['cache' => true, 'value' => $source, 'ttl' => self::$failureCacheTtl, 'reason' => 'empty-translation'];
        }

        if ($translated !== $source) {
            return ['cache' => true, 'value' => $translated, 'ttl' => 0, 'reason' => 'translated'];
        }

        $hasChinese = preg_match('/[\x{4e00}-\x{9fff}]/u', $source);

        if ($hasChinese) {
            $isJaKo = in_array($targetLang, ['jp', 'kor'], true);
            $sourceLen = mb_strlen($source, 'UTF-8');
            if ($isJaKo && $sourceLen < self::JAKO_SHORT_WORD_MAX_LEN) {
                return ['cache' => true, 'value' => $translated, 'ttl' => 0, 'reason' => 'jako-short-word'];
            }
            return ['cache' => true, 'value' => $source, 'ttl' => self::$failureCacheTtl, 'reason' => 'same-with-chinese'];
        }

        return ['cache' => true, 'value' => $translated, 'ttl' => 0, 'reason' => 'no-translation-needed'];
    }

    // ============================================================
    //  HTML 转义
    // ============================================================
    private function escapeTranslatedText($text)
    {
        if ($text === '' || $text === null) return $text;
        $len = strlen($text);
        $result = '';
        $i = 0;
        while ($i < $len) {
            $c = $text[$i];
            if ($c === '&') {
                $rest = substr($text, $i);
                if (preg_match('/^&(?:[a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/', $rest, $m)) {
                    $result .= $m[0];
                    $i += strlen($m[0]);
                    continue;
                }
                $result .= '&amp;';
                $i++;
                continue;
            }
            if ($c === '<')  { $result .= '&lt;';   $i++; continue; }
            if ($c === '>')  { $result .= '&gt;';   $i++; continue; }
            if ($c === '"')  { $result .= '&quot;'; $i++; continue; }
            if ($c === "'")  { $result .= '&#39;';  $i++; continue; }
            $result .= $c;
            $i++;
        }
        return $result;
    }

    // ============================================================
    //  语言代码
    // ============================================================
    private function getBaiduLangCode($lang)
    {
        $map = [
            'zh'=>'zh','en'=>'en','jp'=>'jp','cht'=>'cht','kor'=>'kor',
            'fra'=>'fra','spa'=>'spa','th'=>'th','ara'=>'ara','ru'=>'ru',
            'pt'=>'pt','de'=>'de','it'=>'it','el'=>'el','nl'=>'nl',
            'pl'=>'pl','bul'=>'bul','est'=>'est','dan'=>'dan','fin'=>'fin',
            'cs'=>'cs','rom'=>'rom','slo'=>'slo','swe'=>'swe','hu'=>'hu','vie'=>'vie',
        ];
        return $map[$lang] ?? $lang;
    }

    private function getGoogleLangCode($lang)
    {
        $map = [
            'zh'=>'zh-CN','cht'=>'zh-TW','en'=>'en','jp'=>'ja','kor'=>'ko',
            'fra'=>'fr','spa'=>'es','th'=>'th','ara'=>'ar','ru'=>'ru',
            'pt'=>'pt','de'=>'de','it'=>'it','el'=>'el','nl'=>'nl',
            'pl'=>'pl','bul'=>'bg','est'=>'et','dan'=>'da','fin'=>'fi',
            'cs'=>'cs','rom'=>'ro','slo'=>'sk','swe'=>'sv','hu'=>'hu','vie'=>'vi',
        ];
        return $map[$lang] ?? $lang;
    }

    // ============================================================
    //  日志
    // ============================================================
    private function L($indent, $msg)
    {
        if (!$this->debug) return;
        $pad = str_repeat('  ', $indent);
        echo '<!-- ' . $pad . $msg . " -->\n";
    }

    private function shortText($text, $maxLen = 60)
    {
        $t = str_replace(["\n", "\r", "\t"], ['\\n', '\\r', '\\t'], $text);
        if (mb_strlen($t, 'UTF-8') > $maxLen) {
            $t = mb_substr($t, 0, $maxLen, 'UTF-8') . '…';
        }
        return $t;
    }

    // ============================================================
    //  限速 / 规范化
    // ============================================================
    private function throttle()
    {
        $interval = 1.0 / max(1, $this->qps);
        $now = microtime(true);
        $elapsed = $now - $this->lastRequestTime;
        if ($elapsed < $interval) {
            $sleepUs = (int)(($interval - $elapsed) * 1000000);
            if ($sleepUs > 0) usleep($sleepUs);
        }
        $this->lastRequestTime = microtime(true);
    }

    private function normalizeText($text)
    {
        $text = trim($text);
        $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
        $text = str_replace(["\r\n", "\n", "\r"], ' ', $text);
        $text = preg_replace('/\s+/u', ' ', $text);
        return trim($text);
    }

    // ============================================================
    //  HTTP
    // ============================================================
    private function httpGet($url, $timeout = 30)
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36');
        $body = curl_exec($ch);
        $err = curl_error($ch);
        curl_close($ch);
        if ($err) return ['error' => $err];
        return ['body' => $body];
    }

    /**
     * ★ v5.5:支持动态超时参数
     */
    private function httpPostJson($url, $jsonPayload, $timeout = null)
    {
        if ($timeout === null) $timeout = self::PROXY_TIMEOUT;

        // 最小 1 秒,避免 0 或负数
        $timeout = max(1, (int)ceil($timeout));

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(self::PROXY_CONNECT_TIMEOUT, $timeout));
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        $body = curl_exec($ch);
        $err = curl_error($ch);
        curl_close($ch);
        if ($err) return ['error' => $err];
        return ['body' => $body];
    }

    private function httpPostForm($url, $postData, $timeout = 15)
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_FORBID_REUSE, true);
        curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
        $body = curl_exec($ch);
        $err = curl_error($ch);
        curl_close($ch);
        if ($err) return ['error' => $err];
        return ['body' => $body];
    }

    // ============================================================
    //  百度禁用
    // ============================================================
    private function isBaiduDisabled()
    {
        $until = $this->cacheSys->get(self::BAIDU_DISABLED_KEY);
        if ($until === false) return false;
        $untilTs = (int)$until;
        if ($untilTs <= 0) return false;
        return time() < $untilTs;
    }

    private function disableBaiduUntilNextMonth()
    {
        $nextMonth = strtotime('first day of next month 00:00:00');
        $this->cacheSys->set(self::BAIDU_DISABLED_KEY, $nextMonth, 40 * 86400);
        $this->L(0, '⚠ 百度翻译禁用,禁用至 ' . date('Y-m-d H:i:s', $nextMonth));
        error_log('[Translator] 百度翻译禁用,恢复时间 ' . date('Y-m-d H:i:s', $nextMonth));
    }

    private function isBaiduDisableSignal($errorCode)
    {
        return in_array((string)$errorCode, [
            self::BAIDU_INSUFFICIENT_BALANCE,
            self::BAIDU_SERVICE_CLOSE,
        ], true);
    }

    // ============================================================
    //  Google 熔断
    // ============================================================
    private function getGoogleBreakerState()
    {
        $raw = $this->cacheSys->get(self::GOOGLE_BREAKER_KEY);
        if ($raw === false) return ['fails' => 0, 'until' => 0];
        $state = json_decode($raw, true);
        if (!is_array($state)) return ['fails' => 0, 'until' => 0];
        return [
            'fails' => (int)($state['fails'] ?? 0),
            'until' => (int)($state['until'] ?? 0),
        ];
    }

    private function saveGoogleBreakerState($state)
    {
        $this->cacheSys->set(self::GOOGLE_BREAKER_KEY, json_encode($state), 86400);
    }

    private function isGoogleDisabled()
    {
        $state = $this->getGoogleBreakerState();
        if ($state['until'] <= 0) return false;
        if (time() >= $state['until']) {
            $state['until'] = 0;
            $state['fails'] = 0;
            $this->saveGoogleBreakerState($state);
            $this->L(0, 'Google 熔断已过期,自动恢复');
            return false;
        }
        return true;
    }

    private function recordGoogleFailure()
    {
        if ($this->googleFailureRecordedThisRequest) return;
        $this->googleFailureRecordedThisRequest = true;

        $state = $this->getGoogleBreakerState();
        $state['fails']++;
        $this->L(0, 'Google 失败计数 +1,当前 ' . $state['fails']
            . '/' . self::GOOGLE_FAIL_THRESHOLD);

        if ($state['fails'] >= self::GOOGLE_FAIL_THRESHOLD) {
            $state['until'] = time() + self::GOOGLE_COOLDOWN_SECONDS;
            $state['fails'] = 0;
            $this->L(0, 'Google 累计失败 ' . self::GOOGLE_FAIL_THRESHOLD
                . ' 次,熔断至 ' . date('Y-m-d H:i:s', $state['until']));
            error_log('[Translator] Google 熔断至 ' . date('Y-m-d H:i:s', $state['until']));
        }
        $this->saveGoogleBreakerState($state);
    }

    private function recordGoogleSuccess()
    {
        $state = $this->getGoogleBreakerState();
        if ($state['fails'] > 0) {
            $state['fails'] = 0;
            $this->saveGoogleBreakerState($state);
            $this->L(0, 'Google 成功,失败计数清零');
        }
    }

    // ============================================================
    //  引擎可用性
    // ============================================================
    private function isEngineAvailable($engine)
    {
        switch ($engine) {
            case 'google':
                if (empty($this->googleProxyUrl)) {
                    $this->L(1, '引擎 google 不可用:中转地址未配置');
                    return false;
                }
                if ($this->isGoogleDisabled()) {
                    $state = $this->getGoogleBreakerState();
                    $this->L(1, '引擎 google 熔断中,恢复时间 '
                        . date('Y-m-d H:i:s', $state['until']));
                    return false;
                }
                return true;

            case 'baidu':
                if (empty($this->appId) || empty($this->secretKey)) {
                    $this->L(1, '引擎 baidu 不可用:APP ID 或密钥未配置');
                    return false;
                }
                if ($this->isBaiduDisabled()) {
                    $until = (int)$this->cacheSys->get(self::BAIDU_DISABLED_KEY);
                    $this->L(1, '引擎 baidu 禁用中,恢复时间 '
                        . date('Y-m-d H:i:s', $until));
                    return false;
                }
                return true;

            default:
                return false;
        }
    }

    // ============================================================
    //  引擎 1:词霸(本地 Python)★ v5.5 支持动态超时
    // ============================================================
    private function callGoogleApi($query, $from, $to, $timeout = null)
    {
        if (empty($this->googleProxyUrl)) {
            return ['error' => 'Google 中转地址未配置', 'engine' => 'google'];
        }

        $this->throttle();

        if ($timeout === null) $timeout = self::PROXY_TIMEOUT;

        $payload = json_encode([
            'sl' => $from,
            'tl' => $to,
            'q'  => $query,
        ], JSON_UNESCAPED_UNICODE);

        // ★ v5.5:去掉重试,一次超时立即返回
        $r = $this->httpPostJson($this->googleProxyUrl, $payload, $timeout);
        if (isset($r['error'])) {
            return ['error' => 'cURL error: ' . $r['error'], 'engine' => 'google'];
        }

        $response = $r['body'];

        $decoded = json_decode($response, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            return ['error' => 'JSON解析失败: ' . json_last_error_msg(), 'engine' => 'google'];
        }

        if (!is_array($decoded) || !isset($decoded[0]) || !is_array($decoded[0])) {
            if (isset($decoded['error'])) {
                return ['error' => '中转错误: ' . $decoded['error'], 'engine' => 'google'];
            }
            return ['error' => 'Google 返回结构异常', 'engine' => 'google'];
        }

        $translated = '';
        foreach ($decoded[0] as $seg) {
            if (isset($seg[0])) $translated .= $seg[0];
        }

        return [
            'trans_result' => [['src' => $query, 'dst' => $translated]],
            'engine' => 'google',
        ];
    }

    // ============================================================
    //  引擎 2:百度
    // ============================================================
    private function callBaiduApi($query, $from, $to)
    {
        if (empty($this->appId) || empty($this->secretKey)) {
            return ['error' => '百度翻译未配置', 'engine' => 'baidu'];
        }

        $this->throttle();

        $salt = rand(10000, 99999);
        $sign = md5($this->appId . $query . $salt . $this->secretKey);

        $args = [
            'q'     => $query,
            'appid' => $this->appId,
            'salt'  => $salt,
            'from'  => $from,
            'to'    => $to,
            'sign'  => $sign,
        ];

        $postData = http_build_query($args);

        $r = $this->httpPostForm($this->baiduApiUrl, $postData, 15);
        if (isset($r['error'])) {
            return ['error' => 'cURL error: ' . $r['error'], 'engine' => 'baidu'];
        }

        $decoded = json_decode($r['body'], true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            return ['error' => 'JSON解析失败: ' . json_last_error_msg(), 'engine' => 'baidu'];
        }

        $decoded['engine'] = 'baidu';
        return $decoded;
    }

    private function getSourceLang($engine)
    {
        return ($engine === 'google') ? $this->googleSourceLang : $this->baiduSourceLang;
    }

    private function engineLabel($engine)
    {
        if ($engine === 'google') return '词霸(本地 Python)';
        if ($engine === 'baidu')  return '百度翻译(官方 API)';
        return $engine;
    }

    // ============================================================
    //  百度批量兜底
    // ============================================================
    private function translateWithBaidu($texts, $targetLang)
    {
        $results = [];
        $from = $this->baiduSourceLang;
        $to = $this->getBaiduLangCode($targetLang);

        $chunks = array_chunk($texts, 10);
        foreach ($chunks as $chunk) {
            $query = implode("\n", $chunk);
            $apiResult = $this->callBaiduApi($query, $from, $to);

            if (isset($apiResult['error_code'])) {
                if ($this->isBaiduDisableSignal($apiResult['error_code'])) {
                    $this->disableBaiduUntilNextMonth();
                }
                $this->L(1, '百度 API 错误:' . $apiResult['error_code']);
                continue;
            }

            if (!empty($apiResult['trans_result'])) {
                $parts = [];
                foreach ($apiResult['trans_result'] as $row) {
                    $parts[] = $row['dst'] ?? '';
                }
                if (count($parts) === count($chunk)) {
                    foreach ($chunk as $i => $text) {
                        $results[$text] = $parts[$i];
                    }
                }
            }
        }

        return $results;
    }

    // ============================================================
    //  ★ v5.5:批量翻译主入口(动态超时 + 限条数)
    // ============================================================
    private function translateBatch($texts, $targetLang)
    {
        if (empty($texts)) return [];

        $texts = array_values(array_filter($texts, function ($t) {
            return trim($t) !== '';
        }));
        if (empty($texts)) return [];

        // ① 从缓存获取已翻译结果
        $cached = $this->getTexts($texts, $targetLang);

        $toTranslate = array();
        foreach ($texts as $text) {
            if (!isset($cached[$text])) {
                $toTranslate[] = $text;
            }
        }

        $this->L(1, '【translateBatch】输入 ' . count($texts)
            . ' 条 → 缓存命中 ' . count($cached)
            . ',需翻译 ' . count($toTranslate));

        if (empty($toTranslate)) return $cached;

        // ② 限制单次翻译条数
        if (count($toTranslate) > self::MAX_BATCH_SIZE) {
            $toTranslate = array_slice($toTranslate, 0, self::MAX_BATCH_SIZE);
            $this->L(1, '  限制单次翻译条数为 ' . self::MAX_BATCH_SIZE . ' 条');
        }

        $startTime = microtime(true);
        $cacheNew = $this->getCache($targetLang);

        // ============================================================
        // ③ 阶段1:本地词霸(动态超时 + 分块)
        // ============================================================
        $this->L(0, '');
        $this->L(0, '▶ 阶段1:本地翻译(词霸)');
        $this->L(0, '  超时限制:' . self::TOTAL_TIMEOUT . '秒');
        $this->L(0, '');

        $engineResults = [];
        $failedTexts = [];

        $chunks = array_chunk($toTranslate, self::CHUNK_SIZE);
        $from = $this->getSourceLang('google');
        $to = $this->getGoogleLangCode($targetLang);

        foreach ($chunks as $chunkIdx => $chunk) {
            $elapsed = microtime(true) - $startTime;
            $remaining = self::TOTAL_TIMEOUT - $elapsed;

            if ($remaining <= 0) {
                $this->L(1, '  ⏱ 总超时(' . round($elapsed, 2) . 's),停止本地翻译');
                // 剩余块全部标记为失败
                for ($i = $chunkIdx; $i < count($chunks); $i++) {
                    $failedTexts = array_merge($failedTexts, $chunks[$i]);
                }
                break;
            }

            $this->L(1, '  翻译第 ' . ($chunkIdx + 1) . '/' . count($chunks)
                . ' 块(' . count($chunk) . ' 条),剩余 ' . round($remaining, 2) . 's');

            $query = implode($this->googleBatchSeparator, $chunk);
            $apiResult = $this->callGoogleApi($query, $from, $to, $remaining);

            if (isset($apiResult['error'])) {
                $this->L(2, '  ✘ 块 ' . ($chunkIdx + 1) . ' 失败:' . $apiResult['error']);
                $failedTexts = array_merge($failedTexts, $chunk);
                if (strpos($apiResult['error'], 'timed out') !== false) {
                    // 超时错误,剩余块也别试了
                    for ($i = $chunkIdx + 1; $i < count($chunks); $i++) {
                        $failedTexts = array_merge($failedTexts, $chunks[$i]);
                    }
                    break;
                }
                continue;
            }

            // 检查结果
            $combined = '';
            foreach ($apiResult['trans_result'] as $row) {
                $combined .= $row['dst'] ?? '';
            }
            $parts = explode($this->googleBatchSeparator, $combined);
            $parts = array_map(function ($s) { return trim($s, " \t\n\r"); }, $parts);

            if (count($parts) !== count($chunk)) {
                $this->L(2, '  ✘ 块 ' . ($chunkIdx + 1) . ' 段数不匹配('
                    . count($parts) . '/' . count($chunk) . ')');
                $failedTexts = array_merge($failedTexts, $chunk);
                continue;
            }

            foreach ($chunk as $i => $text) {
                $engineResults[$text] = $parts[$i];
            }
            $this->L(2, '  ✔ 块 ' . ($chunkIdx + 1) . ' 成功');
        }

        $localCost = microtime(true) - $startTime;
        $this->L(1, '阶段1完成:成功 ' . count($engineResults)
            . ' 条,失败 ' . count($failedTexts)
            . ' 条,耗时 ' . round($localCost, 2) . 's');

        // ============================================================
        // ④ 阶段2:百度兜底
        // ============================================================
        $baiduResults = [];
        if (!empty($failedTexts)) {
            if ($this->isEngineAvailable('baidu')) {
                $this->L(0, '');
                $this->L(0, '▶ 阶段2:百度翻译兜底(' . count($failedTexts) . ' 条)');
                $this->L(0, '');

                $baiduResults = $this->translateWithBaidu($failedTexts, $targetLang);
                $this->L(1, '百度翻译完成:成功 ' . count($baiduResults) . ' 条');
            } else {
                $this->L(0, '');
                $this->L(0, '⚠ 百度翻译不可用,失败文本返回原文');
            }
        }

        // ============================================================
        // ⑤ 合并结果,写入缓存
        // ============================================================
        $goodResults = [];

        // 本地成功 + 校验通过
        foreach ($engineResults as $src => $dst) {
            $action = $this->decideCacheAction($src, $dst, $targetLang);
            if ($action['reason'] === 'same-with-chinese'
                || $action['reason'] === 'empty-translation') {
                // 本地无效,尝试百度
                if (isset($baiduResults[$src])) {
                    $baiduAction = $this->decideCacheAction($src, $baiduResults[$src], $targetLang);
                    if ($baiduAction['reason'] !== 'same-with-chinese'
                        && $baiduAction['reason'] !== 'empty-translation') {
                        $goodResults[$src] = $baiduAction['value'];
                    }
                }
            } else {
                $goodResults[$src] = $action['value'];
            }
        }

        // 百度兜底 + 校验通过(本地失败的)
        foreach ($baiduResults as $src => $dst) {
            if (isset($goodResults[$src])) continue;
            $action = $this->decideCacheAction($src, $dst, $targetLang);
            if ($action['reason'] !== 'same-with-chinese'
                && $action['reason'] !== 'empty-translation') {
                $goodResults[$src] = $action['value'];
            }
        }

        // 写入缓存
        if (!empty($goodResults)) {
            $toWrite = [];
            foreach ($goodResults as $src => $dst) {
                $toWrite[md5($src)] = $dst;
            }
            $cacheNew->setMulti($toWrite, 0);
            $cached = array_merge($cached, $goodResults);
            $this->L(1, '写入缓存:' . count($goodResults) . ' 条');
        }

        // 仍然失败的返回原文
        $stillFailed = 0;
        foreach ($toTranslate as $text) {
            if (!isset($goodResults[$text])) {
                $cached[$text] = $text;
                $stillFailed++;
            }
        }

        if ($stillFailed > 0) {
            $this->L(1, '最终失败返回原文:' . $stillFailed . ' 条');
            $this->lastErrorCount += $stillFailed;
        }

        // 补齐
        $finalResult = [];
        foreach ($texts as $text) {
            $finalResult[$text] = isset($cached[$text]) ? $cached[$text] : $text;
        }
        return $finalResult;
    }

    // ============================================================
    //  单条翻译
    // ============================================================
    public function translate($text, $targetLang)
    {
        $text = $this->normalizeText($text);
        if (empty($text) || $targetLang === 'zh') return $text;
        if (!in_array($targetLang, $this->supportedLangs)) return $text;

        $memoKey = $targetLang . '|' . $text;
        if (isset($this->memo[$memoKey])) return $this->memo[$memoKey];

        $cached = $this->getText($text, $targetLang);
        if ($cached !== false) {
            $this->memo[$memoKey] = $cached;
            return $cached;
        }

        $result = $this->translateBatch(array($text), $targetLang);
        $translated = isset($result[$text]) ? $result[$text] : $text;

        $this->memo[$memoKey] = $translated;
        return $translated;
    }

    // ============================================================
    //  DOM 工具
    // ============================================================
    private function loadHtmlDocument($html)
    {
        $dom = new DOMDocument();
        $dom->encoding = 'UTF-8';
        $encoded = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x10FFFF], 'UTF-8');
        @$dom->loadHTML($encoded, LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING);
        return $dom;
    }

    private function saveHtmlClean($dom)
    {
        $html = $dom->saveHTML();
        $html = preg_replace('/^<\?xml[^>]*>/i', '', $html);
        $html = preg_replace('/^<!DOCTYPE[^>]*>/i', '', $html);
        $html = preg_replace('/<\/?(html|head|body|meta)[^>]*>/i', '', $html);

        if (function_exists('mb_chr')) {
            $html = preg_replace_callback('/&#x([0-9a-fA-F]+);/', function ($m) {
                $code = hexdec($m[1]);
                if ($code < 0x80) return $m[0];
                return mb_chr($code, 'UTF-8');
            }, $html);
            $html = preg_replace_callback('/&#(\d+);/', function ($m) {
                $code = (int)$m[1];
                if ($code < 0x80) return $m[0];
                return mb_chr($code, 'UTF-8');
            }, $html);
        }
        return trim($html);
    }

    private function processBodyMarkers($content)
    {
        return preg_replace_callback(
            '/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/',
            function ($m) {
                return $this->wrapHtmlForTranslation($m[1]);
            },
            $content
        );
    }

    private function wrapHtmlForTranslation($html)
    {
        if ($html === '' || $html === null) return '';

        if (stripos($html, '<') === false) {
            $html = trim($html);
            if ($html === '' || !preg_match('/[\x{4e00}-\x{9fff}]/u', $html)) return $html;
            return 'hmcm-' . $html . '-hmcms';
        }

        $originalHtml = $html;
        $dom = $this->loadHtmlDocument($html);
        $xpath = new DOMXPath($dom);

        $skipTags = ['pre', 'code', 'script', 'style', 'textarea'];
        $blockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td', 'th', 'dd', 'dt', 'caption'];

        $textNodes = $xpath->query('//text()');
        if ($textNodes === false) return $originalHtml;

        foreach ($textNodes as $node) {
            $skip = false;
            $p = $node->parentNode;
            while ($p && $p->nodeType === XML_ELEMENT_NODE) {
                if (in_array(strtolower($p->nodeName), $skipTags, true)) {
                    $skip = true;
                    break;
                }
                $p = $p->parentNode;
            }
            if ($skip) continue;

            $isInBlock = false;
            $p = $node->parentNode;
            while ($p && $p->nodeType === XML_ELEMENT_NODE) {
                if (in_array(strtolower($p->nodeName), $blockTags, true)) {
                    $isInBlock = true;
                    break;
                }
                $p = $p->parentNode;
            }
            if (!$isInBlock) continue;

            $rawText = $node->textContent;
            $text = trim($rawText);

            if ($text === '') continue;
            if (mb_strlen($text, 'UTF-8') < 2) continue;
            if (!preg_match('/[\x{4e00}-\x{9fff}]/u', $text)) continue;
            if (strpos($text, 'hmcm-') !== false) continue;

            $leading = '';
            $trailing = '';
            if (preg_match('/^(\s*)/u', $rawText, $m)) $leading = $m[1];
            if (preg_match('/(\s*)$/u', $rawText, $m)) $trailing = $m[1];

            $node->nodeValue = $leading . 'hmcm-' . $text . '-hmcms' . $trailing;
        }

        $result = $this->saveHtmlClean($dom);
        if (trim($result) === '') return $originalHtml;
        return $result;
    }

    private function isHtml($string)
    {
        return preg_match('/<[^>]+>/', $string) > 0;
    }

    private function translateHtmlBatch($htmlList, $targetLang)
    {
        if (empty($htmlList)) return [];

        $this->L(0, 'HTML 片段翻译(批量):' . count($htmlList) . ' 个片段');

        $htmlDocs = [];
        $allTexts = [];

        foreach ($htmlList as $html) {
            $dom = $this->loadHtmlDocument($html);
            $xpath = new DOMXPath($dom);
            $textNodes = $xpath->query('//text()');

            $nodes = [];
            foreach ($textNodes as $node) {
                $text = trim($node->textContent);
                if ($text === '' || mb_strlen($text, 'UTF-8') < 2) continue;
                if (!preg_match('/[\x{4e00}-\x{9fff}]/u', $text)) continue;

                $nodes[] = ['node' => $node, 'text' => $text];
                $allTexts[$text] = true;
            }
            $htmlDocs[] = ['html' => $html, 'dom' => $dom, 'nodes' => $nodes];
        }

        $uniqueTexts = array_keys($allTexts);
        $this->L(0, '  HTML 内文本去重后:' . count($uniqueTexts) . ' 条');

        $translations = [];
        if (!empty($uniqueTexts)) {
            $translations = $this->translateBatch($uniqueTexts, $targetLang);
        }

        $result = [];
        foreach ($htmlDocs as $docInfo) {
            foreach ($docInfo['nodes'] as $item) {
                $src = $item['text'];
                if (isset($translations[$src])) {
                    $item['node']->nodeValue = $translations[$src];
                }
            }
            $rendered = $this->saveHtmlClean($docInfo['dom']);
            $result[$docInfo['html']] = (trim($rendered) !== '') ? $rendered : $docInfo['html'];
        }
        return $result;
    }

    // ============================================================
    //  占位符替换主入口
    // ============================================================
    public function replacePlaceholders($content, $targetLang = null)
    {
        if (self::isBotRequest()) {
            $content = preg_replace('/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/', '$1', $content);
            $content = preg_replace('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', '$1', $content);
            return $content;
        }

        if ($targetLang === null) $targetLang = $this->currentLang;

        $this->lastErrorCount = 0;
        $this->googleFailureRecordedThisRequest = false;

        $__t0 = microtime(true);

        $this->L(0, '');
        $this->L(0, '╔══════════════════════════════════════════╗');
        $this->L(0, '║   翻译开始                                ║');
        $this->L(0, '╚══════════════════════════════════════════╝');
        $this->L(0, '语言:' . $targetLang . '(' . date('Y-m-d H:i:s') . ')');
        $this->L(0, '页面大小:' . strlen($content) . ' 字节');

        if ($targetLang === 'zh') {
            $result = preg_replace('/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/', '$1', $content);
            $result = preg_replace('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', '$1', $result);
            $this->L(0, '简体页面:剥离占位符');
            $this->L(0, '总耗时:' . round((microtime(true) - $__t0) * 1000) . ' ms');
            $this->L(0, '');
            return $result;
        }

        if ($targetLang === 'cht') {
            $content = preg_replace('/\{\{hmcm-html-([\s\S]+?)-hmcms\}\}/', '$1', $content);
            $result = $this->processChinesePage($content, $targetLang);
            $this->L(0, '繁体页面:OpenCC 本地转换');
            $this->L(0, '总耗时:' . round((microtime(true) - $__t0) * 1000) . ' ms');
            $this->L(0, '');
            return $result;
        }

        $content = $this->processBodyMarkers($content);

        preg_match_all('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', $content, $matches);
        if (empty($matches[0])) {
            $this->L(0, '无占位符,直接返回');
            $this->L(0, '');
            return $content;
        }

        $uniqueTexts = [];
        $htmlTexts = [];
        foreach ($matches[1] as $original) {
            $text = $this->normalizeText($original);
            if ($text === '') continue;
            if ($this->isHtml($text)) {
                $htmlTexts[$text] = true;
            } else {
                $uniqueTexts[$text] = true;
            }
        }

        $plainList = array_keys($uniqueTexts);
        $htmlList  = array_keys($htmlTexts);

        $this->L(0, '占位符总数:' . count($matches[0]));
        $this->L(0, '去重后:纯文本 ' . count($plainList) . ' 条,HTML 片段 ' . count($htmlList) . ' 条');

        $translations = $this->getTexts($plainList, $targetLang);

        $toTranslate = array();
        foreach ($plainList as $text) {
            if (!isset($translations[$text])) {
                $toTranslate[] = $text;
            }
        }

        $this->L(0, '缓存命中:' . count($translations) . ' 条');
        $this->L(0, '需翻译:' . count($toTranslate) . ' 条');

        if (!empty($toTranslate)) {
            $batchResult = $this->translateBatch($toTranslate, $targetLang);
            foreach ($batchResult as $src => $dst) {
                $translations[$src] = $dst;
            }
        }

        if (!empty($htmlList)) {
            $htmlTranslations = $this->translateHtmlBatch($htmlList, $targetLang);
            foreach ($htmlTranslations as $src => $dst) {
                $translations[$src] = $dst;
            }
        }

        $result = preg_replace_callback(
            '/\{\{hmcm-([\s\S]+?)-hmcms\}\}/',
            function ($m) use ($translations) {
                $original = trim($m[1]);
                $key = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $original);
                $key = str_replace(["\r\n", "\n", "\r"], ' ', $key);
                $key = preg_replace('/\s+/u', ' ', $key);
                $key = trim($key);
                if ($key === '' || !isset($translations[$key])) return $m[0];
                if ($translations[$key] === '' || $translations[$key] === null) return $m[0];
                return $this->escapeTranslatedText($translations[$key]);
            },
            $content
        );

        $total = round((microtime(true) - $__t0) * 1000);
        $residual = preg_match('/\{\{hmcm-/', $result) ? '⚠ 有残留占位符' : '✔ 全部替换完成';
        $this->L(0, '');
        $this->L(0, '╔══════════════════════════════════════════╗');
        $this->L(0, '║   ' . $residual . '  总耗时 ' . $total . ' ms   ║');
        $this->L(0, '║   错误计数 ' . $this->lastErrorCount . '                       ║');
        $this->L(0, '╚══════════════════════════════════════════╝');
        $this->L(0, '');

        return $result;
    }

    // ============================================================
    //  繁体页面处理
    // ============================================================
    private function processChinesePage($content, $lang)
    {
        $content = preg_replace('/\{\{hmcm-([\s\S]+?)-hmcms\}\}/', '$1', $content);
        if ($lang === 'zh') return $content;
        if (!$this->converterAvailable) return $content;
        return $this->convertWholePage($content, 'S2T');
    }

    private function convertWholePage($html, $strategy)
    {
        $parts = preg_split('/(<[^>]*>)/s', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
        if ($parts === false) return OpenCC::convert($html, $strategy);

        $result = '';
        $count = count($parts);
        for ($i = 0; $i < $count; $i++) {
            $part = $parts[$i];
            if ($i % 2 === 1) { $result .= $part; continue; }
            if ($part === '' || !preg_match('/[\x{4e00}-\x{9fff}]/u', $part)) {
                $result .= $part;
                continue;
            }
            $result .= OpenCC::convert($part, $strategy);
        }
        return $result;
    }
}


七、PHP側の主要設定

// 构造函数中
$this->googleProxyUrl = 'http://127.0.0.1:8000/translate';

// 关键常量(v5.5)
const MAX_BATCH_SIZE         = 30;    // 单次最多翻译 30 条
const TOTAL_TIMEOUT          = 2.0;   // 本地翻译总超时(秒)
const CHUNK_SIZE             = 10;    // 分块大小
const PROXY_TIMEOUT          = 2;     // 默认单次 cURL 超时
const PROXY_CONNECT_TIMEOUT  = 1;     // 连接超时

八、検証リスト

検査項目 コマンド 予想
Pythonのサービス supervisorctl status RUNNING
ポート監視 ss -tlnp | grep 8000 LISTEN
単回翻訳 curl -X POST ... <0.3秒
中国語ページ ホームページへアクセス 0.1–0.3秒
日本語(初回) /jp/ ページにアクセスする 1–3秒
日本語(二次) 再度アクセス 0.1–0.5秒
メモリ使用量 free -h available > 500M

IX. 陥りやすい落とし穴記録

問題 理由 解決
清華大学イメージ 403 一時的な利用制限 北京大学のミラー設定を変更する
`conda` コマンドが見つかりません。 init処理が中断されました。 手動 conda init bash
サービスカード(25秒) 北米代理店のタイムアウト 香港代理ロジックを削除する
ページ処理が滞っている(502) 1回の翻訳件数が多すぎます 最大30件 + 2秒のタイムアウト
cURL:16秒 2回再試行(×8秒) 重複処理を回避して再試行、動的タイムアウト
メモリ不足 余分なプラグインがインストールされています。 PHP 7.4、Docker、Postfixをアンインストールする

十、性能データ

シナリオ 所要時間
30件のICIBA併存症 0.3–0.5秒
単一翻訳 0.05–0.1秒
全キャッシュヒット 0.05秒
最初の画面(30件の翻訳を含む) 0.8–2秒
二次訪問 0.1–0.5秒

十一、段階的翻訳メカニズム

ページに139件の翻訳待ちコンテンツがあります:

アクセス回数 キャッシュ 翻訳する 累計
第1回 42 30 72
第2回 72 30 102
第3回 102 30 132
第4回 132 7 139 ✅

Googlebotは1日で数十回スクリーリングを行い、1〜2日で全サイトの翻訳が完了します。

微信赞赏

WeChat

支付宝赞赏

Alipay

✍️ クリエイター: 紅穆

ウェブサイト管理者 · ご覧いただきありがとうございます!さらに多くの魅力的なコンテンツをお楽しみいただけますよう、引き続きご支援をお願いいたします。

著者プロフィール プロフィールを見る →

関連記事

PHPクイックアンケートシステム

PHPクイックアンケートシステム プログラムノート PHP 趣味 実用収集

アンケート調査システム · 全容チュートリアル 1. システムアーキテクチャ:データフロー → ユーザーがアンケートを記入(index.php) │ │ POST FormData ▼ .api.php ←── 環境情報(IP/OS/ブラウザ/デバイス/時刻)を受信・記録 │ │ JSON形式で書き込み ▼ data/submissions.json │ │ 取得 ▼ ad…
👁 35
EmpireCMSでは、どの程度のデータ量でテーブル分割を行うのが適切でしょうか?公開済みのデータをどのようにテーブル分割に適用すればよいでしょうか?EmpireCMSで100万件のデータを各テーブルに均等に分配するにはどうすればよいでしょうか?

EmpireCMSでは、どの程度のデータ量でテーブル分割を行うのが適切でしょうか?公開済みのデータをどのようにテーブル分割に適用すればよいでしょうか?EmpireCMSで100万件のデータを各テーブルに均等に分配するにはどうすればよいでしょうか? プログラムノート EmpireCMS 実用収集 帝国プラグイン

EmpireCMSでは、データ量がどの程度でテーブル分割を行うのが適切でしょうか?1. データベース容量が50GBの場合:新規にメインテーブルを作成してください。2. データ数が5万件に達した場合:新規に副テーブルを作成し、この新規作成した副テーブルを現在の保存テーブルとして設定してください。【一方で、データ数が10万件程度であれば、一度に1つのテーブルに分割する方法も推奨されています。】データ量が多すぎると、MySQLのデータに対するI/O操作量が非常に大きくなり、サーバ全体の負荷が高まります。特にEmpireCMSの管理画面での操作が遅くなる傾向があります。これは、特にデータ量の多いコーナーにおいて顕著です。私のサイトでは、テーブル分割を行わなかった場合、このような問題が発生していました…
👁 692
EmpireCMSはPHPを用いてデータを直接送信し、記事を更新します。

EmpireCMSはPHPを用いてデータを直接送信し、記事を更新します。 プログラムノート EmpireCMS 言語ノート PHP

提出したコード:暇だったので、「Tianhuo Tou」モジュールを丁寧に研究し、それを EmpireCMS と組み合わせた結果、以下のコードが作成されました。PHPの経験豊富な方はこのコードを自由に編集してください。以下は単なる提出用のコード例であり、データは自分で設定する必要があります!<?php $url = "http://www.4s5.cn/e/admin/123.php"; // 実際の受信データURLに置き換えてください…
👁 390
SQLiteデータベースのオンラインPHPによる読み取り操作の確認、およびPbootCMSデータベースのオンライン確認

SQLiteデータベースのオンラインPHPによる読み取り操作の確認、およびPbootCMSデータベースのオンライン確認 プログラムノート PbootCms 言語ノート PHP

pbootcmsを使用する際、多くの場合データベースを利用しますが、pbootのデータベースはdbファイルであるため、取り扱いがやや困難です。データベースを視覚的に確認できる既存のインターフェースが存在しないため、本システムではPHPによるオンライン確認用視覚化ページを新たに開発しました。これにより、利用が大幅に容易になります。使用方法は以下の通りです。まず、dbファイルのフォルダ内にphpファイルを作成し、以下のコードを記述します。その後、自身のデータベース名を指定し、p…にアクセスすればよいです。
👁 179
EmpireCMS 8.0 の検索結果ページで、動的PHPコードによる表示に対応しています。

EmpireCMS 8.0 の検索結果ページで、動的PHPコードによる表示に対応しています。 プログラムノート EmpireCMS 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-」は第1の変数、2番目の変数は新しく作成されるディレクトリ名です。以下はこの機能を実現するPHP関数です。この関数はまず、指定されたディレクトリ内に「admin-」で始まるディレクトリが存在するかどうかを確認します。もし…
👁 172
PHPでフォルダ内のファイルを取得するコードはありますか?

PHPでフォルダ内のファイルを取得するコードはありますか? 言語ノート PHP

PHPを使用してフォルダ内のすべてのファイルを取得するには、`glob()`関数または`scandir()`関数を用いることができます。以下に、これら2つの方法の例コードを示します。`glob()`関数:`glob()`関数は1つのパラメータを受け取り、指定された条件を満たすすべてのファイルおよびディレクトリを含む配列を返します。ディレクトリ内のすべてのファイルを取得するには、以下のコードを使用できます:`$files = glob('/path/…');`
👁 193
ミュージシャン必携:最完全な楽器音源・音色対照表(中国語・英語対照)!

ミュージシャン必携:最完全な楽器音源・音色対照表(中国語・英語対照)! 落とし穴のまとめ 趣味 編曲学習

ご存知の通り、現在の市場には多くの高度な教材や音源プラグインが英語版で提供されています。不熟悉的な単語を目にした際には、常に翻訳を調べる必要があり、非常に面倒です。そこで本日、「Tutorial君」では、楽器に関する充実した英語・日本語対照表をまとめました。これにより、もう煩雑な翻訳作業は不要です。ぜひ今すぐ学習してみてください!Woodwinds(木管楽器):1. Piccolo(短笛)2. Flute(長笛)3. Soprano(ソプラノ)…
👁 928
(適応型スマートフォン向け)pbootCMS用レスポンシブ食品・スナックチェーン加盟店ウェブサイトテンプレート/日用化学品ウェブサイトのソースコードダウンロード(0218)

(適応型スマートフォン向け)pbootCMS用レスポンシブ食品・スナックチェーン加盟店ウェブサイトテンプレート/日用化学品ウェブサイトのソースコードダウンロード(0218) 実用収集 pbootcmsテンプレート

スマートフォン向けの適応型PbootCMSレスポンシブウェブサイトテンプレート(食品・スナックチェーン加盟店用)。デザインスタイルは明るく食欲をそそるもので、スナック商品の紹介、チェーン加盟ポリシー、および店舗イメージの掲載に最適です。食品ブランドがオンラインで加盟店を獲得し、市場チャネルを拡大するのに役立ちます。テンプレート表示・インストール手順:ウェブサイトバックエンド:/admin.php、ユーザー名:admin、パスワード:admin、解凍パスワード:www.4s5.cn。関連記事…
👁 38