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、网络抖动 */ async function getOutboundWithBackoff(qgOpts, rounds = 5) { 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}` return { proxy: false, httpsAgent: new HttpsProxyAgent(proxyUrl) } } 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 } } /** * @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 }, 5) } 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(600) frag = await getOutboundWithBackoff({ forceRefresh: true }, 4) } 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)) // 通道数为 1 时,服务端释放上一 IP 需要时间;与 QgProxyManager 内 /get 多轮重试配合 await sleep(1400) let frag2 try { frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 5) } catch (eFetch) { logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`) await logSchoolOutbound(logger, '(换新提取失败→将加试直连前再试)', url, { proxy: false }) frag2 = { proxy: false } } if (frag2.proxy === false) { frag2 = null } 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}`) } } 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, debugProxyEnabled, debugProxyAxiosFragment }