const axios = require('axios') const HttpsProxyAgent = require('https-proxy-agent') const QgProxyManager = require('./QgProxyManager') function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } /** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动(青果函数内部已短时持锁 backoff,此处不宜再大) */ async function getOutboundWithBackoff(qgOpts, rounds = 2) { let lastErr for (let i = 0; i < rounds; i++) { try { if (i > 0) await sleep(380 * i * i) return await QgProxyManager.getOutboundAxiosFragment(qgOpts) } catch (e) { lastErr = e } } throw lastErr } /** * Axios 对「HTTPS 目标 + 内置 proxy」在部分环境下会引发 ERR_FR_TOO_MANY_REDIRECTS, * 改用 HttpsProxyAgent 走 CONNECT 隧道。 */ function buildAxiosOutboundConfig(fragment) { if (!fragment || fragment.proxy === false || !fragment.proxy) { return { proxy: false } } const { host, port, auth } = fragment.proxy let userPart = '' if (auth && String(auth.username || '').length > 0) { const u = encodeURIComponent(auth.username) const p = encodeURIComponent(auth.password != null ? String(auth.password) : '') 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, { rejectUnauthorized }) } } function debugProxyEnabled() { return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1' } function debugProxyAxiosFragment() { const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1' const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000) return { proxy: { host, port, protocol: 'http' } } } function briefUrlPath(fullUrl) { try { const u = new URL(fullUrl) return `${u.pathname}${u.search}` } catch { return fullUrl } } function isQgProxyEligibleFailure(err) { if (!err) return false const status = err.response?.status if (status === 407) return true if (status === 502 || status === 503 || status === 504) return true if ( err.code && ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPROTO', 'ERR_FR_TOO_MANY_REDIRECTS'].includes( err.code ) ) { return true } if (err.isAxiosError && !err.response) return true const msg = (err.message || '').toLowerCase() if (msg.includes('timeout') || msg.includes('socket') || msg.includes('network')) return true return false } function summarizeAxiosError(err) { if (!err) return {} return { message: err.message, code: err.code, status: err.response?.status, isAxiosError: err.isAxiosError } } /** 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) */ 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}`) return } const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}` if (opts.skipQgSnapshot) { logger.info( `[lepaoSchoolHttp] ${phase} POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}` ) return } const snap = await QgProxyManager.getCachedParsed() const serverRecord = snap?.server ?? conn 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}` ) } /** * 对 lepao.ctbu.edu.cn 的 POST:优先隧道代理;失败快速直连并记日志(隧道池出口由服务商后台切换)。 */ async function postLepaoSchool(url, data, options = {}) { const { headers = {}, timeout = 15000, logger = null } = options const doPost = async (qgProxyFragment) => { const outbound = buildAxiosOutboundConfig(qgProxyFragment) return axios.post(url, data, { headers, timeout, ...outbound }) } if (debugProxyEnabled()) { const dbg = debugProxyAxiosFragment() await logSchoolOutbound(logger, 'Charles调试代理', url, dbg, { skipQgSnapshot: true }) logger?.info?.('[lepaoSchoolHttp] 使用本地调试代理 LEPAO_DEBUG_PROXY') return doPost(dbg) } const qgOn = await QgProxyManager.isOutboundProxyEnabled() if (!qgOn) { await logSchoolOutbound(logger, '(青果出站未启用)', url, { proxy: false }) return doPost({ proxy: false }) } let frag try { frag = await getOutboundWithBackoff({ forceRefresh: false }, 2) } catch (e0) { logger?.error?.(`[lepaoSchoolHttp] 青果提取多次重试仍失败,改直连: ${e0.message || e0}`) await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false }) await QgProxyManager.recordFallbackDirect({ reason: 'qg_extract_error', ...summarizeAxiosError(e0) }) return doPost({ proxy: false }) } if (frag.proxy === false) { try { await sleep(400) frag = await getOutboundWithBackoff({ forceRefresh: true }, 2) } catch { /* 保持 frag 原状 */ } } if (frag.proxy === false) { logger?.warn?.('[lepaoSchoolHttp] 无可用青果节点,对学校 POST 将直连') await logSchoolOutbound(logger, '(无缓存节点→直连)', url, { proxy: false }) await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_available' }) return doPost({ proxy: false }) } await logSchoolOutbound(logger, '首次请求', url, frag) try { return await doPost(frag) } catch (e1) { if (!isQgProxyEligibleFailure(e1)) throw e1 logger?.warn?.( `[lepaoSchoolHttp] 经代理首次请求失败,将直接回退直连。err=${e1.message || e1} ${JSON.stringify( summarizeAxiosError(e1) )}` ) const tls1 = isProxyTlsHandshakeReset(e1) if (tls1) { logger?.warn?.( '[lepaoSchoolHttp] TLS 握手前经代理断开,隧道池模式直接直连(由服务商后台自动切换出口)' ) await logSchoolOutbound(logger, '(TLS隧道异常→直连)', url, { proxy: false }) await QgProxyManager.recordFallbackDirect({ reason: 'tls_prefinish_reset_direct', path: briefUrlPath(url), ...summarizeAxiosError(e1) }) return doPost({ proxy: false }) } await logSchoolOutbound(logger, '(代理失败→直连)', url, { proxy: false }) await QgProxyManager.recordFallbackDirect({ reason: 'proxy_post_failed_then_direct', path: briefUrlPath(url), ...summarizeAxiosError(e1) }) return doPost({ proxy: false }) } } module.exports = { postLepaoSchool, isQgProxyEligibleFailure, debugProxyEnabled, debugProxyAxiosFragment }