lepaoSchoolHttp.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. const axios = require('axios')
  2. const HttpsProxyAgent = require('https-proxy-agent')
  3. const QgProxyManager = require('./QgProxyManager')
  4. function sleep(ms) {
  5. return new Promise(r => setTimeout(r, ms))
  6. }
  7. /** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动(青果函数内部已短时持锁 backoff,此处不宜再大) */
  8. async function getOutboundWithBackoff(qgOpts, rounds = 2) {
  9. let lastErr
  10. for (let i = 0; i < rounds; i++) {
  11. try {
  12. if (i > 0) await sleep(380 * i * i)
  13. return await QgProxyManager.getOutboundAxiosFragment(qgOpts)
  14. } catch (e) {
  15. lastErr = e
  16. }
  17. }
  18. throw lastErr
  19. }
  20. /**
  21. * Axios 对「HTTPS 目标 + 内置 proxy」在部分环境下会引发 ERR_FR_TOO_MANY_REDIRECTS,
  22. * 改用 HttpsProxyAgent 走 CONNECT 隧道。
  23. */
  24. function buildAxiosOutboundConfig(fragment) {
  25. if (!fragment || fragment.proxy === false || !fragment.proxy) {
  26. return { proxy: false }
  27. }
  28. const { host, port, auth } = fragment.proxy
  29. let userPart = ''
  30. if (auth && String(auth.username || '').length > 0) {
  31. const u = encodeURIComponent(auth.username)
  32. const p = encodeURIComponent(auth.password != null ? String(auth.password) : '')
  33. userPart = `${u}:${p}@`
  34. }
  35. const proxyUrl = `http://${userPart}${host}:${port}`
  36. const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
  37. return {
  38. proxy: false,
  39. httpsAgent: new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
  40. }
  41. }
  42. function debugProxyEnabled() {
  43. return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
  44. }
  45. function debugProxyAxiosFragment() {
  46. const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
  47. const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
  48. return {
  49. proxy: {
  50. host,
  51. port,
  52. protocol: 'http'
  53. }
  54. }
  55. }
  56. function briefUrlPath(fullUrl) {
  57. try {
  58. const u = new URL(fullUrl)
  59. return `${u.pathname}${u.search}`
  60. } catch {
  61. return fullUrl
  62. }
  63. }
  64. function isQgProxyEligibleFailure(err) {
  65. if (!err) return false
  66. const status = err.response?.status
  67. if (status === 407) return true
  68. if (status === 502 || status === 503 || status === 504) return true
  69. if (
  70. err.code &&
  71. ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPROTO', 'ERR_FR_TOO_MANY_REDIRECTS'].includes(
  72. err.code
  73. )
  74. ) {
  75. return true
  76. }
  77. if (err.isAxiosError && !err.response) return true
  78. const msg = (err.message || '').toLowerCase()
  79. if (msg.includes('timeout') || msg.includes('socket') || msg.includes('network')) return true
  80. return false
  81. }
  82. function summarizeAxiosError(err) {
  83. if (!err) return {}
  84. return {
  85. message: err.message,
  86. code: err.code,
  87. status: err.response?.status,
  88. isAxiosError: err.isAxiosError
  89. }
  90. }
  91. /** CONNECT Tunnel 后与目标站 TLS 握手前被断开——换 IP 往往无效,应少打 /get、尽快直连 */
  92. function isProxyTlsHandshakeReset(err) {
  93. if (!err) return false
  94. const code = err.code
  95. if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
  96. const msg = String(err.message || '')
  97. return /tls|secure\s+tls|handshake/i.test(msg)
  98. }
  99. /**
  100. * @param {*} logger Worker logger 或 null
  101. * @param {{ skipQgSnapshot?: boolean }} opts 为 true 时仅用 axios 配置的 host:port(如 Charles)
  102. */
  103. async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
  104. if (!logger?.info) return
  105. const path = briefUrlPath(url)
  106. if (!axiosMerge || axiosMerge.proxy === false || !axiosMerge.proxy) {
  107. logger.info(`[lepaoSchoolHttp] ${phase} POST 出站=直连 path=${path}`)
  108. return
  109. }
  110. const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}`
  111. if (opts.skipQgSnapshot) {
  112. logger.info(
  113. `[lepaoSchoolHttp] ${phase} POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
  114. )
  115. return
  116. }
  117. const snap = await QgProxyManager.getCachedParsed()
  118. const serverRecord = snap?.server ?? conn
  119. const egress = snap?.proxyIp ?? '(暂无 proxy_ip)'
  120. const dl = snap?.deadline ?? '—'
  121. logger.info(
  122. `[lepaoSchoolHttp] ${phase} POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
  123. )
  124. }
  125. /**
  126. * 对 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
  127. */
  128. async function postLepaoSchool(url, data, options = {}) {
  129. const { headers = {}, timeout = 15000, logger = null } = options
  130. const doPost = async (qgProxyFragment) => {
  131. const outbound = buildAxiosOutboundConfig(qgProxyFragment)
  132. return axios.post(url, data, {
  133. headers,
  134. timeout,
  135. ...outbound
  136. })
  137. }
  138. if (debugProxyEnabled()) {
  139. const dbg = debugProxyAxiosFragment()
  140. await logSchoolOutbound(logger, 'Charles调试代理', url, dbg, { skipQgSnapshot: true })
  141. logger?.info?.('[lepaoSchoolHttp] 使用本地调试代理 LEPAO_DEBUG_PROXY')
  142. return doPost(dbg)
  143. }
  144. const qgOn = await QgProxyManager.isOutboundProxyEnabled()
  145. if (!qgOn) {
  146. await logSchoolOutbound(logger, '(青果出站未启用)', url, { proxy: false })
  147. return doPost({ proxy: false })
  148. }
  149. let frag
  150. try {
  151. frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
  152. } catch (e0) {
  153. logger?.error?.(`[lepaoSchoolHttp] 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
  154. await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false })
  155. await QgProxyManager.recordFallbackDirect({
  156. reason: 'qg_extract_error',
  157. ...summarizeAxiosError(e0)
  158. })
  159. return doPost({ proxy: false })
  160. }
  161. if (frag.proxy === false) {
  162. try {
  163. await sleep(400)
  164. frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
  165. } catch {
  166. /* 保持 frag 原状 */
  167. }
  168. }
  169. if (frag.proxy === false) {
  170. logger?.warn?.('[lepaoSchoolHttp] 无可用青果节点,对学校 POST 将直连')
  171. await logSchoolOutbound(logger, '(无缓存节点→直连)', url, { proxy: false })
  172. await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_available' })
  173. return doPost({ proxy: false })
  174. }
  175. await logSchoolOutbound(logger, '首次请求', url, frag)
  176. try {
  177. return await doPost(frag)
  178. } catch (e1) {
  179. if (!isQgProxyEligibleFailure(e1)) throw e1
  180. logger?.warn?.(
  181. `[lepaoSchoolHttp] 经代理首次请求失败,将作废IP并换新。err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
  182. )
  183. await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
  184. const tls1 = isProxyTlsHandshakeReset(e1)
  185. /**
  186. * TLS 握手前即 ECONNRESET:换出口 IP 往往仍失败;此时立即再 forceRefresh /get 容易与并发任务
  187. * 抢单通道配额,大面积 NO_AVAILABLE_CHANNEL。线上「脚本测主页 GET OK、接口 POST 仍失败」也属同类。
  188. * 作废缓存后直接直连,省配额且与用户最终成功路径一致。
  189. */
  190. if (tls1) {
  191. logger?.warn?.(
  192. '[lepaoSchoolHttp] TLS 握手前经代理断开,跳过二次青果提取,直接直连以避免 NO_AVAILABLE_CHANNEL 与无谓重试'
  193. )
  194. await logSchoolOutbound(logger, '(TLS隧道异常→跳过换新→直连)', url, { proxy: false })
  195. await QgProxyManager.recordFallbackDirect({
  196. reason: 'tls_prefinish_reset_direct_skip_qg_refresh',
  197. path: briefUrlPath(url),
  198. ...summarizeAxiosError(e1)
  199. })
  200. return doPost({ proxy: false })
  201. }
  202. await sleep(800)
  203. let frag2
  204. try {
  205. frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 2)
  206. } catch (eFetch) {
  207. logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`)
  208. await logSchoolOutbound(logger, '(换新提取失败→直连)', url, { proxy: false })
  209. await QgProxyManager.recordFallbackDirect({
  210. reason: 'qg_extract_after_proxy_fail',
  211. ...summarizeAxiosError(eFetch)
  212. })
  213. return doPost({ proxy: false })
  214. }
  215. if (frag2.proxy === false) {
  216. await logSchoolOutbound(logger, '(换新无节点→直连)', url, { proxy: false })
  217. await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: false })
  218. return doPost({ proxy: false })
  219. }
  220. await logSchoolOutbound(logger, '换新IP重试', url, frag2)
  221. try {
  222. return await doPost(frag2)
  223. } catch (e2) {
  224. if (!isQgProxyEligibleFailure(e2)) throw e2
  225. const tls2 = isProxyTlsHandshakeReset(e2)
  226. logger?.warn?.(
  227. `[lepaoSchoolHttp] 换新IP后仍失败,将回退直连。err=${e2.message || e2} ${JSON.stringify(
  228. summarizeAxiosError(e2)
  229. )}`
  230. )
  231. await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
  232. await logSchoolOutbound(logger, tls2 ? '(二次仍TLS隧道失败→直连)' : '(二次代理失败→直连)', url, {
  233. proxy: false
  234. })
  235. await QgProxyManager.recordFallbackDirect({
  236. reason: tls2 ? 'tls_tunnel_broken_twice' : 'proxy_post_retry_failed',
  237. ...summarizeAxiosError(e2)
  238. })
  239. return doPost({ proxy: false })
  240. }
  241. }
  242. }
  243. module.exports = {
  244. postLepaoSchool,
  245. isQgProxyEligibleFailure,
  246. debugProxyEnabled,
  247. debugProxyAxiosFragment
  248. }