|
|
@@ -6,8 +6,8 @@ function sleep(ms) {
|
|
|
return new Promise(r => setTimeout(r, ms))
|
|
|
}
|
|
|
|
|
|
-/** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动 */
|
|
|
-async function getOutboundWithBackoff(qgOpts, rounds = 5) {
|
|
|
+/** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动(青果函数内部已短时持锁 backoff,此处不宜再大) */
|
|
|
+async function getOutboundWithBackoff(qgOpts, rounds = 2) {
|
|
|
let lastErr
|
|
|
for (let i = 0; i < rounds; i++) {
|
|
|
try {
|
|
|
@@ -36,9 +36,10 @@ function buildAxiosOutboundConfig(fragment) {
|
|
|
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)
|
|
|
+ httpsAgent: new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -96,6 +97,15 @@ function summarizeAxiosError(err) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/** 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)
|
|
|
@@ -104,13 +114,13 @@ 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}`)
|
|
|
+ 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}`
|
|
|
+ `[lepaoSchoolHttp] ${phase} POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
|
|
|
)
|
|
|
return
|
|
|
}
|
|
|
@@ -119,12 +129,12 @@ async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
|
|
|
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}`
|
|
|
+ `[lepaoSchoolHttp] ${phase} POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
|
|
|
)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 对学校 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
|
|
|
+ * 对 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
|
|
|
*/
|
|
|
async function postLepaoSchool(url, data, options = {}) {
|
|
|
const { headers = {}, timeout = 15000, logger = null } = options
|
|
|
@@ -153,7 +163,7 @@ async function postLepaoSchool(url, data, options = {}) {
|
|
|
|
|
|
let frag
|
|
|
try {
|
|
|
- frag = await getOutboundWithBackoff({ forceRefresh: false }, 5)
|
|
|
+ frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
|
|
|
} catch (e0) {
|
|
|
logger?.error?.(`[lepaoSchoolHttp] 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
|
|
|
await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false })
|
|
|
@@ -166,8 +176,8 @@ async function postLepaoSchool(url, data, options = {}) {
|
|
|
|
|
|
if (frag.proxy === false) {
|
|
|
try {
|
|
|
- await sleep(600)
|
|
|
- frag = await getOutboundWithBackoff({ forceRefresh: true }, 4)
|
|
|
+ await sleep(400)
|
|
|
+ frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
|
|
|
} catch {
|
|
|
/* 保持 frag 原状 */
|
|
|
}
|
|
|
@@ -190,68 +200,53 @@ async function postLepaoSchool(url, data, options = {}) {
|
|
|
)
|
|
|
await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
|
|
|
|
|
|
- // 通道数为 1 时,服务端释放上一 IP 需要时间;与 QgProxyManager 内 /get 多轮重试配合
|
|
|
- await sleep(1400)
|
|
|
+ const tls1 = isProxyTlsHandshakeReset(e1)
|
|
|
+ // TLS 握手前断连时通道往往系统性不可用,短时退避即可,避免与其它任务争抢 /get
|
|
|
+ await sleep(tls1 ? 400 : 800)
|
|
|
|
|
|
let frag2
|
|
|
try {
|
|
|
- frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 5)
|
|
|
+ frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 2)
|
|
|
} catch (eFetch) {
|
|
|
logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`)
|
|
|
- await logSchoolOutbound(logger, '(换新提取失败→将加试直连前再试)', url, { proxy: false })
|
|
|
- frag2 = { proxy: false }
|
|
|
+ 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) {
|
|
|
- frag2 = null
|
|
|
+ await logSchoolOutbound(logger, '(换新无节点→直连)', url, { proxy: false })
|
|
|
+ await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: tls1 })
|
|
|
+ return doPost({ proxy: false })
|
|
|
}
|
|
|
|
|
|
- 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}`)
|
|
|
- }
|
|
|
+ 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 })
|
|
|
}
|
|
|
-
|
|
|
- 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,
|