| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- 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
- }
|