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] 经代理首次请求失败,将作废IP并换新。err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}` ) await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1)) const tls1 = isProxyTlsHandshakeReset(e1) // TLS 握手前断连时通道往往系统性不可用,短时退避即可,避免与其它任务争抢 /get await sleep(tls1 ? 400 : 800) let frag2 try { frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 2) } catch (eFetch) { logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`) 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) { await logSchoolOutbound(logger, '(换新无节点→直连)', url, { proxy: false }) await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: tls1 }) return doPost({ proxy: false }) } 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 }) } } } module.exports = { postLepaoSchool, isQgProxyEligibleFailure, debugProxyEnabled, debugProxyAxiosFragment }