Browse Source

🧪 test: 增加代理测试代码

Pchen0 6 hours ago
parent
commit
ec401e57a4

+ 92 - 83
lib/Lepao/QgProxyManager.js

@@ -11,10 +11,13 @@ const REDIS_CURRENT = 'lepao:qg:current'
 const REDIS_LOCK = 'lepao:qg:fetch_lock'
 /** 早于 deadline 提前刷新,尽量减少「边检边过期」(参考 [文档](https://www.qg.net/doc/1850.html)) */
 const DEADLINE_MARGIN_MS = 18_000
-const LOCK_TTL_SEC = 75
+/** 仅覆盖单次 /get(含 axios 超时),避免长持锁阻塞其它任务 */
+const LOCK_TTL_SEC = 45
 const LOCK_WAIT_ROUNDS = 40
 const LOCK_WAIT_MS = 150
 
+let warnedTlsRejectUnauthorized = false
+
 const logger = new Logger(path.join(__dirname, '../logs/QgProxyManager.log'), 'INFO')
 
 function sleep(ms) {
@@ -132,10 +135,9 @@ async function releaseFetchLock() {
     }
 }
 
-/** 瞬时故障 / 通道释放延迟等可 backoff 再试(见青果通道提取说明) */
+/** 瞬时故障 / 通道释放延迟等可 backoff 再试(见青果通道提取说明)。REQUEST_LIMIT_EXCEEDED 再刷 /get 会恶化限流,不在此列。 */
 const RETRYABLE_EXTRACT_CODES = new Set([
     'NO_AVAILABLE_CHANNEL',
-    'REQUEST_LIMIT_EXCEEDED',
     'INTERNAL_ERROR',
     'FAILED_OPERATION',
     'NO_RESOURCE_FOUND'
@@ -178,63 +180,14 @@ async function extractOnce(settings) {
     throw err
 }
 
-async function fetchFromQgUnderLock(settings) {
-    let lastErr
-    const maxAttempts = 8
-
-    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-        try {
-            const { body, item, code } = await extractOnce(settings)
-            const server = item.server
-            const deadline = item.deadline
-            const proxyIp = item.proxy_ip
-            const requestId = body.request_id
-            if (!server) throw new Error('青果返回无 server 字段')
-
-            const deadlineMs = parseDeadlineMs(deadline)
-            const ttlSec = deadlineMs
-                ? Math.max(15, Math.floor((deadlineMs - Date.now()) / 1000) - 2)
-                : 55
-
-            const payload = {
-                server,
-                deadline: deadline || '',
-                deadlineMs: deadlineMs || null,
-                proxyIp: proxyIp || null,
-                requestId: requestId || null,
-                fetchedAt: Date.now()
-            }
-            await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.min(120, Math.max(15, ttlSec)) })
-
-            if (attempt > 1) {
-                logger.info(`[QgProxy] 第 ${attempt} 次 /get 成功`)
-            }
-
-            logger.info(
-                `[QgProxy] 已获取代理节点 server=${server} proxy_ip=${proxyIp ?? '—'} deadline=${deadline || '—'} request_id=${requestId ?? '—'}`
-            )
-
-            await recordLog({
-                event: 'fetch',
-                server,
-                deadline: deadline || null,
-                detail: { request_id: requestId, proxy_ip: proxyIp, code }
-            })
-
-            return payload
-        } catch (e) {
-            lastErr = e
-            const retryable = e.retryable === true
-            if (!retryable || attempt >= maxAttempts) {
-                throw e
-            }
-            const backoff = Math.min(5200, 380 * attempt * attempt)
-            logger.warn(`[QgProxy] /get 将重试 (${attempt}/${maxAttempts}) ${e.message},等待 ${backoff}ms`)
-            await sleep(backoff)
-        }
+function warnIfTlsVerifyDisabled() {
+    if (warnedTlsRejectUnauthorized) return
+    if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
+        warnedTlsRejectUnauthorized = true
+        logger.warn(
+            '[QgProxy] 检测到 NODE_TLS_REJECT_UNAUTHORIZED=0,将关闭 TLS 证书校验,存在中间人风险;生产环境建议移除此环境变量。'
+        )
     }
-
-    throw lastErr || new Error('青果提取失败')
 }
 
 /**
@@ -273,6 +226,7 @@ async function isOutboundProxyEnabled() {
  */
 async function getOutboundAxiosFragment(opt = {}) {
     const forceRefresh = opt.forceRefresh === true
+    warnIfTlsVerifyDisabled()
     if (!hasExtractCredentials()) return { proxy: false }
 
     const settings = await loadSettings()
@@ -291,36 +245,91 @@ async function getOutboundAxiosFragment(opt = {}) {
         }
     }
 
-    const locked = await acquireFetchLock()
-    if (!locked) {
-        logger.warn('青果提取锁等待超时,本次尝试使用缓存或直连由调用方处理')
-        const cached = await getCachedParsed()
-        if (cached?.server) {
-            logger.warn(
-                `[QgProxy] 锁超时降级使用仍为缓存记录的节点 server=${cached.server} proxy_ip=${cached.proxyIp ?? '—'}(可能已过期)`
-            )
-            return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
-        }
-        return { proxy: false }
-    }
-
-    try {
-        if (!forceRefresh) {
+    const maxAttempts = 5
+    let lastErr
+    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+        const locked = await acquireFetchLock()
+        if (!locked) {
+            logger.warn('青果提取锁等待超时,本次尝试使用缓存或直连由调用方处理')
             const cached = await getCachedParsed()
-            if (cacheStillValid(cached)) {
+            if (cached?.server) {
+                logger.warn(
+                    `[QgProxy] 锁超时降级使用仍为缓存记录的节点 server=${cached.server} proxy_ip=${cached.proxyIp ?? '—'}(可能已过期)`
+                )
                 return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
             }
+            return { proxy: false }
         }
 
-        logger.info(`[QgProxy] 调用青果 /get 拉取新代理... forceRefresh=${forceRefresh}`)
-        const fresh = await fetchFromQgUnderLock(settings)
-        return axiosProxyOptsFromServer(fresh.server, hasProxyAuth())
-    } catch (e) {
-        logger.error(`青果拉取异常: ${e.stack || e}`)
-        throw e
-    } finally {
-        await releaseFetchLock()
+        let shouldBackoff = false
+        try {
+            if (!forceRefresh) {
+                const cached = await getCachedParsed()
+                if (cacheStillValid(cached)) {
+                    return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
+                }
+            }
+
+            logger.info(`[QgProxy] 调用青果 /get 拉取新代理... forceRefresh=${forceRefresh}`)
+            const { body, item, code } = await extractOnce(settings)
+            const server = item.server
+            const deadline = item.deadline
+            const proxyIp = item.proxy_ip
+            const requestId = body.request_id
+            if (!server) throw new Error('青果返回无 server 字段')
+
+            const deadlineMs = parseDeadlineMs(deadline)
+            const ttlSec = deadlineMs
+                ? Math.max(15, Math.floor((deadlineMs - Date.now()) / 1000) - 2)
+                : 55
+
+            const payload = {
+                server,
+                deadline: deadline || '',
+                deadlineMs: deadlineMs || null,
+                proxyIp: proxyIp || null,
+                requestId: requestId || null,
+                fetchedAt: Date.now()
+            }
+            await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.min(120, Math.max(15, ttlSec)) })
+
+            if (attempt > 1) {
+                logger.info(`[QgProxy] 第 ${attempt} 次 /get 成功`)
+            }
+
+            logger.info(
+                `[QgProxy] 已获取代理节点 server=${server} proxy_ip=${proxyIp ?? '—'} deadline=${deadline || '—'} request_id=${requestId ?? '—'}`
+            )
+
+            await recordLog({
+                event: 'fetch',
+                server,
+                deadline: deadline || null,
+                detail: { request_id: requestId, proxy_ip: proxyIp, code }
+            })
+
+            return axiosProxyOptsFromServer(server, hasProxyAuth())
+        } catch (e) {
+            lastErr = e
+            shouldBackoff = e.retryable === true && attempt < maxAttempts
+            if (shouldBackoff) {
+                const backoff = Math.min(2000, 280 * attempt * attempt)
+                logger.warn(`[QgProxy] /get 将重试 (${attempt}/${maxAttempts}) ${e.message},等待 ${backoff}ms`)
+            } else {
+                logger.error(`青果拉取异常: ${e.stack || e}`)
+                throw e
+            }
+        } finally {
+            await releaseFetchLock()
+        }
+
+        if (shouldBackoff) {
+            const backoff = Math.min(2000, 280 * attempt * attempt)
+            await sleep(backoff)
+        }
     }
+
+    throw lastErr || new Error('青果提取失败')
 }
 
 async function invalidateCurrent(reason, detail) {

+ 2 - 1
lib/Lepao/Worker.js

@@ -45,7 +45,8 @@ class Worker {
         this.channelName = 'lepao_worker'
 
         this.maxRetry = 3
-        this.timeout = 15000
+        /** 青果提取 + 代理 POST 重试需略长于单次 axios,避免误报「任务超时」而死信 */
+        this.timeout = 22000
         this.maxQueueLength = 2000
 
         this.defaultUserAgent = 'Mozilla/5.0 (Linux; Android 16; 2211133C Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'

+ 1 - 1
lib/Lepao/lepaoProxyLogDisplay.js

@@ -34,7 +34,7 @@ function summarizeLogRow(record) {
     } else if (event === 'fallback_direct') {
         if (d.reason) lines.push(`触发原因:${d.reason}`)
         if (d.reason === 'exhausted_proxy_then_direct') {
-            lines.push('多轮提取与经代理 POST 均未成功,已改直连对学校接口')
+            lines.push('多轮提取与经代理 POST 均未成功,已改直连接口')
         } else if (d.message) {
             lines.push(`说明:${d.message}`)
         }

+ 53 - 58
lib/Lepao/lepaoSchoolHttp.js

@@ -6,8 +6,8 @@ function sleep(ms) {
     return new Promise(r => setTimeout(r, ms))
 }
 
-/** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动 */
-async function getOutboundWithBackoff(qgOpts, rounds = 5) {
+/** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动(青果函数内部已短时持锁 backoff,此处不宜再大) */
+async function getOutboundWithBackoff(qgOpts, rounds = 2) {
     let lastErr
     for (let i = 0; i < rounds; i++) {
         try {
@@ -36,9 +36,10 @@ function buildAxiosOutboundConfig(fragment) {
         userPart = `${u}:${p}@`
     }
     const proxyUrl = `http://${userPart}${host}:${port}`
+    const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
     return {
         proxy: false,
-        httpsAgent: new HttpsProxyAgent(proxyUrl)
+        httpsAgent: new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
     }
 }
 
@@ -96,6 +97,15 @@ function summarizeAxiosError(err) {
     }
 }
 
+/** CONNECT Tunnel 后与目标站 TLS 握手前被断开——换 IP 往往无效,应少打 /get、尽快直连 */
+function isProxyTlsHandshakeReset(err) {
+    if (!err) return false
+    const code = err.code
+    if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
+    const msg = String(err.message || '')
+    return /tls|secure\s+tls|handshake/i.test(msg)
+}
+
 /**
  * @param {*} logger Worker logger 或 null
  * @param {{ skipQgSnapshot?: boolean }} opts 为 true 时仅用 axios 配置的 host:port(如 Charles)
@@ -104,13 +114,13 @@ async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
     if (!logger?.info) return
     const path = briefUrlPath(url)
     if (!axiosMerge || axiosMerge.proxy === false || !axiosMerge.proxy) {
-        logger.info(`[lepaoSchoolHttp] ${phase} 对学校 POST 出站=直连 path=${path}`)
+        logger.info(`[lepaoSchoolHttp] ${phase} POST 出站=直连 path=${path}`)
         return
     }
     const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}`
     if (opts.skipQgSnapshot) {
         logger.info(
-            `[lepaoSchoolHttp] ${phase} 对学校 POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
+            `[lepaoSchoolHttp] ${phase} POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
         )
         return
     }
@@ -119,12 +129,12 @@ async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
     const egress = snap?.proxyIp ?? '(暂无 proxy_ip)'
     const dl = snap?.deadline ?? '—'
     logger.info(
-        `[lepaoSchoolHttp] ${phase} 对学校 POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
+        `[lepaoSchoolHttp] ${phase} POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
     )
 }
 
 /**
- * 对学校 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
+ * 对 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
  */
 async function postLepaoSchool(url, data, options = {}) {
     const { headers = {}, timeout = 15000, logger = null } = options
@@ -153,7 +163,7 @@ async function postLepaoSchool(url, data, options = {}) {
 
     let frag
     try {
-        frag = await getOutboundWithBackoff({ forceRefresh: false }, 5)
+        frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
     } catch (e0) {
         logger?.error?.(`[lepaoSchoolHttp] 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
         await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false })
@@ -166,8 +176,8 @@ async function postLepaoSchool(url, data, options = {}) {
 
     if (frag.proxy === false) {
         try {
-            await sleep(600)
-            frag = await getOutboundWithBackoff({ forceRefresh: true }, 4)
+            await sleep(400)
+            frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
         } catch {
             /* 保持 frag 原状 */
         }
@@ -190,68 +200,53 @@ async function postLepaoSchool(url, data, options = {}) {
         )
         await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
 
-        // 通道数为 1 时,服务端释放上一 IP 需要时间;与 QgProxyManager 内 /get 多轮重试配合
-        await sleep(1400)
+        const tls1 = isProxyTlsHandshakeReset(e1)
+        // TLS 握手前断连时通道往往系统性不可用,短时退避即可,避免与其它任务争抢 /get
+        await sleep(tls1 ? 400 : 800)
 
         let frag2
         try {
-            frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 5)
+            frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 2)
         } catch (eFetch) {
             logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`)
-            await logSchoolOutbound(logger, '(换新提取失败→将加试直连前再试)', url, { proxy: false })
-            frag2 = { proxy: false }
+            await logSchoolOutbound(logger, '(换新提取失败→直连)', url, { proxy: false })
+            await QgProxyManager.recordFallbackDirect({
+                reason: 'qg_extract_after_proxy_fail',
+                ...summarizeAxiosError(eFetch)
+            })
+            return doPost({ proxy: false })
         }
 
         if (frag2.proxy === false) {
-            frag2 = null
+            await logSchoolOutbound(logger, '(换新无节点→直连)', url, { proxy: false })
+            await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: tls1 })
+            return doPost({ proxy: false })
         }
 
-        if (frag2) {
-            await logSchoolOutbound(logger, '换新IP重试', url, frag2)
-            try {
-                return await doPost(frag2)
-            } catch (e2) {
-                if (!isQgProxyEligibleFailure(e2)) throw e2
-                logger?.warn?.(
-                    `[lepaoSchoolHttp] 换新IP后仍失败,将进入加试阶段。err=${e2.message || e2} ${JSON.stringify(
-                        summarizeAxiosError(e2)
-                    )}`
-                )
-                await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
-            }
-        }
-
-        for (let extra = 0; extra < 5; extra++) {
-            await sleep(750 + 400 * extra * extra)
-            try {
-                const fragX = await getOutboundWithBackoff({ forceRefresh: true }, 5)
-                if (fragX.proxy === false) continue
-                await logSchoolOutbound(logger, `补充轮次-${extra + 1}`, url, fragX)
-                try {
-                    return await doPost(fragX)
-                } catch (e4) {
-                    if (!isQgProxyEligibleFailure(e4)) throw e4
-                    await QgProxyManager.invalidateCurrent('extra_round_fail', summarizeAxiosError(e4))
-                }
-            } catch (eGrab) {
-                logger?.warn?.(`[lepaoSchoolHttp] 补充轮提取失败 (${extra + 1}): ${eGrab.message || eGrab}`)
-            }
+        await logSchoolOutbound(logger, '换新IP重试', url, frag2)
+        try {
+            return await doPost(frag2)
+        } catch (e2) {
+            if (!isQgProxyEligibleFailure(e2)) throw e2
+            const tls2 = isProxyTlsHandshakeReset(e2)
+            logger?.warn?.(
+                `[lepaoSchoolHttp] 换新IP后仍失败,将回退直连。err=${e2.message || e2} ${JSON.stringify(
+                    summarizeAxiosError(e2)
+                )}`
+            )
+            await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
+            await logSchoolOutbound(logger, tls1 && tls2 ? '(连续TLS隧道失败→直连)' : '(二次代理失败→直连)', url, {
+                proxy: false
+            })
+            await QgProxyManager.recordFallbackDirect({
+                reason: tls1 && tls2 ? 'tls_tunnel_broken_twice' : 'proxy_post_retry_failed',
+                ...summarizeAxiosError(e2)
+            })
+            return doPost({ proxy: false })
         }
-
-        logger?.warn?.(`[lepaoSchoolHttp] 多轮代理均不可用,回退直连 path=${briefUrlPath(url)}`)
-        await logSchoolOutbound(logger, '(多轮失败后→直连)', url, { proxy: false })
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'exhausted_proxy_then_direct',
-            message: lastNote()
-        })
-        return doPost({ proxy: false })
     }
 }
 
-function lastNote() {
-    return '已用尽提取与 POST 加试仍失败或未取到可用节点'
-}
-
 module.exports = {
     postLepaoSchool,
     isQgProxyEligibleFailure,

+ 169 - 0
scripts/lepao-proxy-tls-test.js

@@ -0,0 +1,169 @@
+#!/usr/bin/env node
+'use strict'
+
+/**
+ * 在学校服务器上测试「直连 vs HTTP 代理」访问 lepao HTTPS 时 TLS 是否正常。
+ *
+ * 用法(在项目根 ic-ctbu-backend 下执行):
+ *   node scripts/lepao-proxy-tls-test.js direct
+ *   node scripts/lepao-proxy-tls-test.js proxy 103.217.191.35:20028
+ *   node scripts/lepao-proxy-tls-test.js qg-fetch       # 用 config.json 调青果 /get 取 1 个节点再测
+ *
+ * 环境变量(可选):
+ *   TEST_HTTPS_URL   默认 https://lepao.ctbu.edu.cn/(也可用具体 API,如 …/WpIndex/getOssSts)
+ *   TEST_POST        若为 1,则对 TEST_HTTPS_URL 发 POST(body 为空 JSON),更接近业务
+ *   TEST_TIMEOUT_MS  默认 20000
+ *   QG_PROXY_USER / QG_PROXY_PASSWORD  覆盖 config 里 authUser/authPassword
+ *   QG_AREA / QG_AREA_EX / QG_ISP / QG_DISTINCT  qg-fetch 时传给青果(与线上一致可用来复现)
+ *
+ *   NODE_TLS_REJECT_UNAUTHORIZED=0  不推荐生产使用;设为 0 时本脚本与其它行为一致会做不安全 TLS
+ */
+
+const path = require('path')
+const fs = require('fs')
+const axios = require('axios')
+const HttpsProxyAgent = require('https-proxy-agent')
+
+const QG_GET_URL = 'https://share.proxy.qg.net/get'
+
+function loadConfig() {
+    const p = path.join(__dirname, '..', 'config.json')
+    const raw = fs.readFileSync(p, 'utf8')
+    return JSON.parse(raw)
+}
+
+function buildProxyUrl(server, authUser, authPassword) {
+    const parts = String(server || '').trim().split(':')
+    const host = parts[0]
+    const port = parts[1]
+    if (!host || !port) throw new Error('代理 server 需为 host:port')
+    let userPart = ''
+    if (authUser) {
+        const u = encodeURIComponent(authUser)
+        const p = encodeURIComponent(authPassword != null ? String(authPassword) : '')
+        userPart = `${u}:${p}@`
+    }
+    return `http://${userPart}${host}:${port}`
+}
+
+async function probe(label, outbound) {
+    const url = process.env.TEST_HTTPS_URL || 'https://lepao.ctbu.edu.cn/'
+    const timeout = Number(process.env.TEST_TIMEOUT_MS || 20000)
+    const usePost = String(process.env.TEST_POST || '').trim() === '1'
+    const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
+
+    const base = {
+        timeout,
+        validateStatus: () => true,
+        ...outbound
+    }
+
+    console.log(`\n---------- ${label} ----------`)
+    console.log('REQUEST:', usePost ? 'POST' : 'GET', url)
+    console.log('timeout_ms:', timeout, 'rejectUnauthorized:', rejectUnauthorized)
+
+    const t0 = Date.now()
+    try {
+        const res = usePost
+            ? await axios.post(url, {}, { headers: { 'Content-Type': 'application/json' }, ...base })
+            : await axios.get(url, base)
+        const ms = Date.now() - t0
+        console.log('RESULT: OK', ms + 'ms', 'HTTP', res.status)
+        const bodyPreview = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
+        console.log('body_preview:', bodyPreview.slice(0, 300).replace(/\s+/g, ' '))
+    } catch (e) {
+        const ms = Date.now() - t0
+        console.log('RESULT: FAIL', ms + 'ms')
+        console.log('message:', e.message)
+        console.log('code:', e.code)
+        if (e.response) console.log('http_status:', e.response.status)
+    }
+}
+
+async function fetchOneProxyFromQg(config) {
+    const q = config.qgChannelProxy || {}
+    const key = String(q.extractKey || '').trim()
+    if (!key) throw new Error('config.json 缺少 qgChannelProxy.extractKey')
+
+    const params = { key, num: 1 }
+    const area = String(process.env.QG_AREA || '').trim()
+    const areaEx = String(process.env.QG_AREA_EX || '').trim()
+    if (area) params.area = area
+    if (areaEx) params.area_ex = areaEx
+    const isp = process.env.QG_ISP
+    if (isp === '1' || isp === '2' || isp === '3') params.isp = Number(isp)
+    if (process.env.QG_DISTINCT === '0') params.distinct = false
+    else params.distinct = true
+
+    const res = await axios.get(QG_GET_URL, {
+        params,
+        timeout: 20000,
+        proxy: false,
+        validateStatus: () => true
+    })
+    const body = res.data
+    const code = body && body.code
+    if (code !== 'SUCCESS' || !Array.isArray(body.data) || !body.data[0]) {
+        throw new Error(`青果 /get 失败: ${code || JSON.stringify(body).slice(0, 240)}`)
+    }
+    const item = body.data[0]
+    return {
+        server: item.server,
+        proxy_ip: item.proxy_ip,
+        deadline: item.deadline,
+        request_id: body.request_id
+    }
+}
+
+async function main() {
+    const mode = (process.argv[2] || 'direct').toLowerCase()
+    const cfg = loadConfig()
+    const q = cfg.qgChannelProxy || {}
+    const authUser = process.env.QG_PROXY_USER || q.authUser || ''
+    const authPassword = process.env.QG_PROXY_PASSWORD || q.authPassword || ''
+
+    if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
+        console.warn('[WARN] NODE_TLS_REJECT_UNAUTHORIZED=0,TLS 未校验服务端证书。\n')
+    }
+
+    await probe('① 直连(无代理)', { proxy: false })
+
+    if (mode === 'direct') return
+
+    let serverArg
+    if (mode === 'proxy') {
+        serverArg = process.argv[3]
+        if (!serverArg) {
+            console.error('用法: node scripts/lepao-proxy-tls-test.js proxy host:port')
+            process.exit(1)
+        }
+    } else if (mode === 'qg-fetch') {
+        console.log('\n[qg-fetch] 正在请求青果 /get …')
+        const got = await fetchOneProxyFromQg(cfg)
+        serverArg = got.server
+        console.log(
+            '青果节点:',
+            got.server,
+            'proxy_ip=',
+            got.proxy_ip,
+            'deadline=',
+            got.deadline,
+            'request_id=',
+            got.request_id
+        )
+        if (!serverArg) throw new Error('青果返回无 server')
+    } else {
+        console.error('未知模式:', mode, '可用: direct | proxy <host:port> | qg-fetch')
+        process.exit(1)
+    }
+
+    const proxyUrl = buildProxyUrl(serverArg, authUser, authPassword)
+    const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
+    const agent = new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
+    await probe(`② HTTP 代理 → 学校 HTTPS (${serverArg})`, { proxy: false, httpsAgent: agent })
+}
+
+main().catch((e) => {
+    console.error(e.stack || e)
+    process.exit(1)
+})