lepaoSchoolHttp.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  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、网络抖动 */
  8. async function getOutboundWithBackoff(qgOpts, rounds = 5) {
  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. return {
  37. proxy: false,
  38. httpsAgent: new HttpsProxyAgent(proxyUrl)
  39. }
  40. }
  41. function debugProxyEnabled() {
  42. return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
  43. }
  44. function debugProxyAxiosFragment() {
  45. const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
  46. const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
  47. return {
  48. proxy: {
  49. host,
  50. port,
  51. protocol: 'http'
  52. }
  53. }
  54. }
  55. function briefUrlPath(fullUrl) {
  56. try {
  57. const u = new URL(fullUrl)
  58. return `${u.pathname}${u.search}`
  59. } catch {
  60. return fullUrl
  61. }
  62. }
  63. function isQgProxyEligibleFailure(err) {
  64. if (!err) return false
  65. const status = err.response?.status
  66. if (status === 407) return true
  67. if (status === 502 || status === 503 || status === 504) return true
  68. if (
  69. err.code &&
  70. ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPROTO', 'ERR_FR_TOO_MANY_REDIRECTS'].includes(
  71. err.code
  72. )
  73. ) {
  74. return true
  75. }
  76. if (err.isAxiosError && !err.response) return true
  77. const msg = (err.message || '').toLowerCase()
  78. if (msg.includes('timeout') || msg.includes('socket') || msg.includes('network')) return true
  79. return false
  80. }
  81. function summarizeAxiosError(err) {
  82. if (!err) return {}
  83. return {
  84. message: err.message,
  85. code: err.code,
  86. status: err.response?.status,
  87. isAxiosError: err.isAxiosError
  88. }
  89. }
  90. /**
  91. * @param {*} logger Worker logger 或 null
  92. * @param {{ skipQgSnapshot?: boolean }} opts 为 true 时仅用 axios 配置的 host:port(如 Charles)
  93. */
  94. async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
  95. if (!logger?.info) return
  96. const path = briefUrlPath(url)
  97. if (!axiosMerge || axiosMerge.proxy === false || !axiosMerge.proxy) {
  98. logger.info(`[lepaoSchoolHttp] ${phase} 对学校 POST 出站=直连 path=${path}`)
  99. return
  100. }
  101. const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}`
  102. if (opts.skipQgSnapshot) {
  103. logger.info(
  104. `[lepaoSchoolHttp] ${phase} 对学校 POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
  105. )
  106. return
  107. }
  108. const snap = await QgProxyManager.getCachedParsed()
  109. const serverRecord = snap?.server ?? conn
  110. const egress = snap?.proxyIp ?? '(暂无 proxy_ip)'
  111. const dl = snap?.deadline ?? '—'
  112. logger.info(
  113. `[lepaoSchoolHttp] ${phase} 对学校 POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
  114. )
  115. }
  116. /**
  117. * 对学校 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
  118. */
  119. async function postLepaoSchool(url, data, options = {}) {
  120. const { headers = {}, timeout = 15000, logger = null } = options
  121. const doPost = async (qgProxyFragment) => {
  122. const outbound = buildAxiosOutboundConfig(qgProxyFragment)
  123. return axios.post(url, data, {
  124. headers,
  125. timeout,
  126. ...outbound
  127. })
  128. }
  129. if (debugProxyEnabled()) {
  130. const dbg = debugProxyAxiosFragment()
  131. await logSchoolOutbound(logger, 'Charles调试代理', url, dbg, { skipQgSnapshot: true })
  132. logger?.info?.('[lepaoSchoolHttp] 使用本地调试代理 LEPAO_DEBUG_PROXY')
  133. return doPost(dbg)
  134. }
  135. const qgOn = await QgProxyManager.isOutboundProxyEnabled()
  136. if (!qgOn) {
  137. await logSchoolOutbound(logger, '(青果出站未启用)', url, { proxy: false })
  138. return doPost({ proxy: false })
  139. }
  140. let frag
  141. try {
  142. frag = await getOutboundWithBackoff({ forceRefresh: false }, 5)
  143. } catch (e0) {
  144. logger?.error?.(`[lepaoSchoolHttp] 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
  145. await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false })
  146. await QgProxyManager.recordFallbackDirect({
  147. reason: 'qg_extract_error',
  148. ...summarizeAxiosError(e0)
  149. })
  150. return doPost({ proxy: false })
  151. }
  152. if (frag.proxy === false) {
  153. try {
  154. await sleep(600)
  155. frag = await getOutboundWithBackoff({ forceRefresh: true }, 4)
  156. } catch {
  157. /* 保持 frag 原状 */
  158. }
  159. }
  160. if (frag.proxy === false) {
  161. logger?.warn?.('[lepaoSchoolHttp] 无可用青果节点,对学校 POST 将直连')
  162. await logSchoolOutbound(logger, '(无缓存节点→直连)', url, { proxy: false })
  163. await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_available' })
  164. return doPost({ proxy: false })
  165. }
  166. await logSchoolOutbound(logger, '首次请求', url, frag)
  167. try {
  168. return await doPost(frag)
  169. } catch (e1) {
  170. if (!isQgProxyEligibleFailure(e1)) throw e1
  171. logger?.warn?.(
  172. `[lepaoSchoolHttp] 经代理首次请求失败,将作废IP并换新。err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
  173. )
  174. await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
  175. // 通道数为 1 时,服务端释放上一 IP 需要时间;与 QgProxyManager 内 /get 多轮重试配合
  176. await sleep(1400)
  177. let frag2
  178. try {
  179. frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 5)
  180. } catch (eFetch) {
  181. logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`)
  182. await logSchoolOutbound(logger, '(换新提取失败→将加试直连前再试)', url, { proxy: false })
  183. frag2 = { proxy: false }
  184. }
  185. if (frag2.proxy === false) {
  186. frag2 = null
  187. }
  188. if (frag2) {
  189. await logSchoolOutbound(logger, '换新IP重试', url, frag2)
  190. try {
  191. return await doPost(frag2)
  192. } catch (e2) {
  193. if (!isQgProxyEligibleFailure(e2)) throw e2
  194. logger?.warn?.(
  195. `[lepaoSchoolHttp] 换新IP后仍失败,将进入加试阶段。err=${e2.message || e2} ${JSON.stringify(
  196. summarizeAxiosError(e2)
  197. )}`
  198. )
  199. await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
  200. }
  201. }
  202. for (let extra = 0; extra < 5; extra++) {
  203. await sleep(750 + 400 * extra * extra)
  204. try {
  205. const fragX = await getOutboundWithBackoff({ forceRefresh: true }, 5)
  206. if (fragX.proxy === false) continue
  207. await logSchoolOutbound(logger, `补充轮次-${extra + 1}`, url, fragX)
  208. try {
  209. return await doPost(fragX)
  210. } catch (e4) {
  211. if (!isQgProxyEligibleFailure(e4)) throw e4
  212. await QgProxyManager.invalidateCurrent('extra_round_fail', summarizeAxiosError(e4))
  213. }
  214. } catch (eGrab) {
  215. logger?.warn?.(`[lepaoSchoolHttp] 补充轮提取失败 (${extra + 1}): ${eGrab.message || eGrab}`)
  216. }
  217. }
  218. logger?.warn?.(`[lepaoSchoolHttp] 多轮代理均不可用,回退直连 path=${briefUrlPath(url)}`)
  219. await logSchoolOutbound(logger, '(多轮失败后→直连)', url, { proxy: false })
  220. await QgProxyManager.recordFallbackDirect({
  221. reason: 'exhausted_proxy_then_direct',
  222. message: lastNote()
  223. })
  224. return doPost({ proxy: false })
  225. }
  226. }
  227. function lastNote() {
  228. return '已用尽提取与 POST 加试仍失败或未取到可用节点'
  229. }
  230. module.exports = {
  231. postLepaoSchool,
  232. isQgProxyEligibleFailure,
  233. debugProxyEnabled,
  234. debugProxyAxiosFragment
  235. }