2026-06-23 08:04:04 +00:00
|
|
|
|
"""
|
2026-06-30 14:03:48 +00:00
|
|
|
|
AO3 反代后端 v5 — 粘性会话 + Service Worker + P2C 代理池 + 并发限流
|
|
|
|
|
|
|
|
|
|
|
|
v5 vs v4:
|
|
|
|
|
|
- 粘性会话: ao3_sessid_proxy cookie (对标 go3)
|
|
|
|
|
|
- Service Worker: /sw.js, /sw-YYYYMMDD.js for client-side caching
|
|
|
|
|
|
- HTML 注入: 自动注入 SW 注册脚本 + 镜像域名
|
|
|
|
|
|
- P2C 代理选择: Power of Two Choices (对标 go3)
|
|
|
|
|
|
- 并发限流: asyncio.Semaphore(400) (Webshare 上限)
|
|
|
|
|
|
- 全量 5000 代理: 不再按端口过滤
|
|
|
|
|
|
- 状态机: healthy/unstable/blocked/probing
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"""
|
|
|
|
|
|
import hashlib
|
2026-06-30 14:03:48 +00:00
|
|
|
|
import html
|
2026-06-23 08:04:04 +00:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import secrets
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, Request, Response
|
|
|
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
from proxy_pool import get_proxy_pool, MAX_CONCURRENT_REQUESTS, OUTAGE_THRESHOLD
|
|
|
|
|
|
from ao3_fetcher import fetch_url, is_cf_challenge, FAST_REQUEST_TIMEOUT, NORMAL_REQUEST_TIMEOUT
|
|
|
|
|
|
from url_rewriter import rewrite_body, rewrite_response_headers, rewrite_response_headers_raw, needs_rewrite, MIRROR_DOMAIN
|
2026-06-23 08:04:04 +00:00
|
|
|
|
from cache import get_cache, get_ttl_for_path
|
|
|
|
|
|
from stats import get_stats_collector
|
|
|
|
|
|
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
|
|
|
|
handlers=[logging.StreamHandler()],
|
|
|
|
|
|
)
|
|
|
|
|
|
logger = logging.getLogger("ao3-backend")
|
|
|
|
|
|
|
|
|
|
|
|
AO3_BASE = "https://archiveofourown.org"
|
|
|
|
|
|
MIRROR_HOST = MIRROR_DOMAIN
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Service Worker version date (change on SW updates)
|
|
|
|
|
|
SERVICE_WORKER_DATE = "20260623"
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"}
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── Challenge Token Map (v4 retained) ──────────────────────────────────────
|
2026-06-23 08:04:04 +00:00
|
|
|
|
_challenge_map: dict[str, tuple] = {}
|
|
|
|
|
|
_challenge_lock = threading.Lock()
|
2026-06-30 14:03:48 +00:00
|
|
|
|
CHALLENGE_TOKEN_TTL = 120
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_challenge_token() -> str:
|
|
|
|
|
|
return secrets.token_urlsafe(16)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _store_challenge(token: str, method: str, ao3_url: str, headers: dict,
|
|
|
|
|
|
body: bytes, cookies: dict, proxy_host: str):
|
|
|
|
|
|
with _challenge_lock:
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
expired = [k for k, v in _challenge_map.items() if v[6] < now]
|
|
|
|
|
|
for k in expired:
|
|
|
|
|
|
del _challenge_map[k]
|
|
|
|
|
|
_challenge_map[token] = (method, ao3_url, headers, body, cookies,
|
|
|
|
|
|
proxy_host, now + CHALLENGE_TOKEN_TTL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_challenge(token: str) -> tuple | None:
|
|
|
|
|
|
with _challenge_lock:
|
|
|
|
|
|
entry = _challenge_map.get(token)
|
|
|
|
|
|
if entry and entry[6] > time.time():
|
|
|
|
|
|
del _challenge_map[token]
|
|
|
|
|
|
return entry
|
|
|
|
|
|
if entry:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
del _challenge_map[token]
|
2026-06-23 08:04:04 +00:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── Sticky Session ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
STICKY_COOKIE_NAME = "ao3_sessid_proxy"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_sticky_proxy_idx(request: Request) -> tuple[int, bool]:
|
|
|
|
|
|
"""Get sticky proxy index from cookie. Returns (idx, had_sticky)."""
|
|
|
|
|
|
c = request.cookies.get(STICKY_COOKIE_NAME)
|
|
|
|
|
|
if c is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
idx = int(c)
|
|
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
if 0 <= idx < len(pool.proxies):
|
|
|
|
|
|
return idx, True
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
# No sticky cookie — random
|
|
|
|
|
|
import random
|
|
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
return random.randint(0, max(len(pool.proxies) - 1, 0)), False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
# ─── App ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(
|
|
|
|
|
|
title="AO3 Mirror",
|
|
|
|
|
|
description="AO3 reverse proxy mirror for Chinese users",
|
2026-06-30 14:03:48 +00:00
|
|
|
|
version="5.0.0",
|
2026-06-23 08:04:04 +00:00
|
|
|
|
docs_url=None,
|
|
|
|
|
|
redoc_url=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_client_ip(request: Request) -> str:
|
|
|
|
|
|
cf_ip = request.headers.get("CF-Connecting-IP")
|
|
|
|
|
|
if cf_ip:
|
|
|
|
|
|
return cf_ip
|
|
|
|
|
|
forwarded = request.headers.get("X-Forwarded-For")
|
|
|
|
|
|
if forwarded:
|
|
|
|
|
|
return forwarded.split(",")[0].strip()
|
|
|
|
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_ao3_url(path: str, query: str = "") -> str:
|
|
|
|
|
|
url = f"{AO3_BASE}{path}"
|
|
|
|
|
|
if query:
|
|
|
|
|
|
url = f"{url}?{query}"
|
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Challenge page URL rewriting ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _rewrite_challenge_page(body: bytes, proxy_host: str, token: str) -> bytes:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"""Rewrite CF challenge page — inject retry UI + auto cookie."""
|
|
|
|
|
|
# Rewrite domain
|
2026-06-23 08:04:04 +00:00
|
|
|
|
body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode())
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Rewrite CF challenge paths — our CF CDN would intercept /cdn-cgi/,
|
|
|
|
|
|
# so we remap to /__cf/ which our proxy handler forwards to AO3
|
|
|
|
|
|
body = body.replace(b"/cdn-cgi/", b"/__cf/")
|
|
|
|
|
|
body = body.replace(b"cdn-cgi/challenge", b"__cf/challenge")
|
|
|
|
|
|
|
|
|
|
|
|
# Inject cookie + retry overlay
|
|
|
|
|
|
retry_overlay = (
|
|
|
|
|
|
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'.encode()
|
|
|
|
|
|
+ b'<style>#cf-retry-overlay{position:fixed;top:0;left:0;right:0;background:#990000;color:#fff;text-align:center;padding:12px;z-index:99999;font-family:sans-serif}'
|
|
|
|
|
|
+ b'#cf-retry-overlay a{color:#fff;font-weight:bold;text-decoration:underline}</style>'
|
|
|
|
|
|
+ b'<div id="cf-retry-overlay">'
|
|
|
|
|
|
+ 'AO3 Mirror - CF challenge solving in background. '.encode()
|
|
|
|
|
|
+ '<a href="javascript:location.reload()">Click to retry</a> after a few seconds.'.encode()
|
|
|
|
|
|
+ b'</div>'
|
2026-06-23 08:04:04 +00:00
|
|
|
|
)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
body = body.replace(b"<body", retry_overlay + b"<body", 1)
|
|
|
|
|
|
if b"<body" not in body:
|
|
|
|
|
|
body = retry_overlay + body
|
|
|
|
|
|
return body
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── HTML injection (Service Worker) ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def inject_service_worker(html_body: bytes) -> bytes:
|
|
|
|
|
|
"""Inject SW registration script into HTML, before </head>."""
|
|
|
|
|
|
import re
|
|
|
|
|
|
sw_script = (
|
|
|
|
|
|
b'<script>'
|
|
|
|
|
|
b'if("serviceWorker"in navigator){'
|
|
|
|
|
|
b'navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){})'
|
|
|
|
|
|
b'}'
|
|
|
|
|
|
b'</script>'
|
|
|
|
|
|
)
|
|
|
|
|
|
# Match </head> with optional leading whitespace
|
|
|
|
|
|
match = re.search(rb'\s*</head>', html_body)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
pos = match.start()
|
|
|
|
|
|
return html_body[:pos] + sw_script + html_body[pos:]
|
|
|
|
|
|
# Fallback: insert at beginning if no head tag
|
|
|
|
|
|
return sw_script + html_body
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Response header helpers ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def filter_response_headers(headers: dict) -> dict:
|
|
|
|
|
|
blocked = {
|
|
|
|
|
|
"transfer-encoding", "content-encoding",
|
|
|
|
|
|
"alt-svc", "cf-ray", "cf-cache-status", "cf-request-id",
|
|
|
|
|
|
"server", "x-powered-by",
|
|
|
|
|
|
"cross-origin-resource-policy", "cross-origin-embedder-policy",
|
|
|
|
|
|
"cross-origin-opener-policy", "cross-origin-window-policy",
|
|
|
|
|
|
"accept-ch", "critical-ch",
|
|
|
|
|
|
}
|
|
|
|
|
|
result = {}
|
|
|
|
|
|
for key, value in headers.items():
|
|
|
|
|
|
if key.lower() in blocked:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if key.lower().startswith("cf-"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
result[key] = value
|
2026-06-30 14:03:48 +00:00
|
|
|
|
result["X-Proxy"] = "AO3-Mirror/5.0"
|
2026-06-23 08:04:04 +00:00
|
|
|
|
result["X-Cache"] = "MISS"
|
|
|
|
|
|
result["Cache-Control"] = "public, max-age=60, s-maxage=60"
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_cors(response: Response):
|
|
|
|
|
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
|
|
|
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, HEAD, OPTIONS, PUT, DELETE, PATCH"
|
|
|
|
|
|
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Cookie, X-CSRF-Token"
|
|
|
|
|
|
response.headers["Access-Control-Max-Age"] = "86400"
|
|
|
|
|
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Local Routes ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
|
|
|
|
async def health():
|
|
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
stats = pool.get_stats()
|
2026-06-30 14:03:48 +00:00
|
|
|
|
return JSONResponse({
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"status": "ok",
|
|
|
|
|
|
"timestamp": time.time(),
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"version": "5.0.0",
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"proxy_pool": stats,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/robots.txt")
|
|
|
|
|
|
async def robots():
|
|
|
|
|
|
return PlainTextResponse(
|
|
|
|
|
|
"User-agent: *\nDisallow: /stats\nDisallow: /health\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── Service Worker Routes ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/sw.js")
|
|
|
|
|
|
async def service_worker_redirect():
|
|
|
|
|
|
"""Redirect to versioned SW file."""
|
|
|
|
|
|
return RedirectResponse(url=f"/sw-{SERVICE_WORKER_DATE}.js", status_code=302)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/sw-{date}.js")
|
|
|
|
|
|
async def service_worker(date: str):
|
|
|
|
|
|
"""Serve versioned Service Worker."""
|
|
|
|
|
|
sw_path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(sw_path, "rb") as f:
|
|
|
|
|
|
sw_content = f.read()
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
return Response(status_code=404)
|
|
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
|
content=sw_content,
|
|
|
|
|
|
media_type="application/javascript",
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Cache-Control": "public, max-age=86400, immutable",
|
|
|
|
|
|
"Service-Worker-Allowed": "/",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/mirror-domains.json")
|
|
|
|
|
|
async def mirror_domains():
|
|
|
|
|
|
"""JSON endpoint for SW cross-domain awareness."""
|
|
|
|
|
|
return JSONResponse({
|
|
|
|
|
|
"domains": [MIRROR_DOMAIN],
|
|
|
|
|
|
"primary": MIRROR_DOMAIN,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
# ─── Stats Dashboard ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/stats", response_class=HTMLResponse)
|
|
|
|
|
|
async def stats_page():
|
|
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
cache = get_cache()
|
|
|
|
|
|
|
|
|
|
|
|
stats = collector.get_overview()
|
|
|
|
|
|
proxy_stats = pool.get_stats()
|
|
|
|
|
|
cache_stats = cache.get_stats()
|
|
|
|
|
|
|
|
|
|
|
|
hourly = stats.get("hourly", [])
|
|
|
|
|
|
chart_labels = json.dumps([
|
|
|
|
|
|
time.strftime("%H:%M", time.localtime(h["hour"]))
|
|
|
|
|
|
for h in hourly[-24:]
|
|
|
|
|
|
])
|
|
|
|
|
|
chart_requests = json.dumps([h["total"] for h in hourly[-24:]])
|
|
|
|
|
|
chart_success = json.dumps([h["successful"] for h in hourly[-24:]])
|
|
|
|
|
|
chart_elapsed = json.dumps([h["avg_elapsed"] * 1000 for h in hourly[-24:]])
|
|
|
|
|
|
|
|
|
|
|
|
return f"""<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-06-30 14:03:48 +00:00
|
|
|
|
<title>AO3 Mirror - 统计面板 v5</title>
|
2026-06-23 08:04:04 +00:00
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
|
|
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
|
background: #0f0f1a; color: #e0e0e0; padding: 20px; }}
|
|
|
|
|
|
.container {{ max-width: 1200px; margin: 0 auto; }}
|
|
|
|
|
|
h1 {{ font-size: 1.8em; margin-bottom: 20px; color: #990000; }}
|
|
|
|
|
|
h2 {{ font-size: 1.2em; margin-bottom: 12px; color: #ccc; }}
|
|
|
|
|
|
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 15px; margin-bottom: 25px; }}
|
|
|
|
|
|
.card {{ background: #1a1a2e; border-radius: 10px; padding: 18px; border: 1px solid #2a2a40; }}
|
|
|
|
|
|
.card .label {{ font-size: 0.8em; color: #888; margin-bottom: 5px; text-transform: uppercase; }}
|
|
|
|
|
|
.card .value {{ font-size: 1.8em; font-weight: bold; }}
|
|
|
|
|
|
.card .sub {{ font-size: 0.85em; color: #999; margin-top: 4px; }}
|
|
|
|
|
|
.green {{ color: #4ade80; }}
|
|
|
|
|
|
.red {{ color: #f87171; }}
|
|
|
|
|
|
.yellow {{ color: #fbbf24; }}
|
|
|
|
|
|
.blue {{ color: #60a5fa; }}
|
|
|
|
|
|
.purple {{ color: #a78bfa; }}
|
|
|
|
|
|
.orange {{ color: #fb923c; }}
|
|
|
|
|
|
.chart-container {{ background: #1a1a2e; border-radius: 10px; padding: 18px; margin-bottom: 25px; border: 1px solid #2a2a40; }}
|
|
|
|
|
|
.chart-row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }}
|
|
|
|
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
|
|
|
|
th, td {{ padding: 8px 12px; text-align: left; border-bottom: 1px solid #2a2a40; font-size: 0.9em; }}
|
|
|
|
|
|
th {{ color: #888; text-transform: uppercase; font-size: 0.8em; }}
|
|
|
|
|
|
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; }}
|
|
|
|
|
|
.badge-green {{ background: rgba(74, 222, 128, 0.15); color: #4ade80; }}
|
|
|
|
|
|
.badge-red {{ background: rgba(248, 113, 113, 0.15); color: #f87171; }}
|
2026-06-30 14:03:48 +00:00
|
|
|
|
.badge-yellow {{ background: rgba(251, 191, 36, 0.15); color: #fbbf24; }}
|
2026-06-23 08:04:04 +00:00
|
|
|
|
@media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="container">
|
2026-06-30 14:03:48 +00:00
|
|
|
|
<h1>🛡 AO3 Mirror 统计面板 v5</h1>
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="grid">
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">总请求数</div>
|
|
|
|
|
|
<div class="value blue">{stats['total_requests']:,}</div>
|
|
|
|
|
|
<div class="sub">运行 {stats['uptime_human']}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">最近 1 分钟</div>
|
|
|
|
|
|
<div class="value yellow">{stats['recent_1m']:,} req</div>
|
|
|
|
|
|
<div class="sub">最近 5 分钟: {stats['recent_5m']:,}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">成功率</div>
|
|
|
|
|
|
<div class="value green">{stats['success_rate']}%</div>
|
|
|
|
|
|
<div class="sub">失败: {stats['failed']:,}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">缓存命中率</div>
|
|
|
|
|
|
<div class="value purple">{stats['cache_rate']}%</div>
|
|
|
|
|
|
<div class="sub">已缓存: {stats['cached']:,}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">平均延迟</div>
|
|
|
|
|
|
<div class="value">{stats['avg_elapsed_ms']} ms</div>
|
|
|
|
|
|
<div class="sub">P99: {stats['p99_elapsed_ms']} ms</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
2026-06-30 14:03:48 +00:00
|
|
|
|
<div class="label">代理池 ({proxy_stats['total']})</div>
|
|
|
|
|
|
<div class="value green">{proxy_stats['healthy']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
|
|
|
|
|
|
<div class="sub">不稳定: {proxy_stats['unstable']} | 封禁: {proxy_stats['blocked']} | 探测: {proxy_stats['probing']}</div>
|
2026-06-23 08:04:04 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">Cookie 代理</div>
|
2026-06-30 14:03:48 +00:00
|
|
|
|
<div class="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
|
|
|
|
|
|
<div class="sub">inflight: {proxy_stats['total_inflight']}</div>
|
2026-06-23 08:04:04 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="label">本地缓存</div>
|
|
|
|
|
|
<div class="value">{cache_stats['size']}<span style="font-size:0.5em;color:#888;">/{cache_stats['capacity']}</span></div>
|
|
|
|
|
|
<div class="sub">已用: {cache_stats['usage_pct']}%</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="chart-row">
|
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
|
<h2>请求趋势 (最近 24 小时)</h2>
|
|
|
|
|
|
<canvas id="requestChart" height="150"></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
|
<h2>响应延迟 (最近 24 小时)</h2>
|
|
|
|
|
|
<canvas id="latencyChart" height="150"></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="chart-container">
|
|
|
|
|
|
<h2>热门路径</h2>
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>路径</th><th>请求数</th><th>成功</th><th>平均延迟</th></tr>
|
2026-06-30 14:03:48 +00:00
|
|
|
|
{''.join(f'<tr><td>{html.escape(p["path"])}</td><td>{p["requests"]:,}</td><td><span class="badge {"badge-green" if p["requests"]==0 or p["successful"]/max(p["requests"],1)>0.8 else "badge-red"}">{round(p["successful"]/max(p["requests"],1)*100)}%</span></td><td>{p["avg_elapsed"]*1000:.0f}ms</td></tr>' for p in stats['top_paths'][:15])}
|
2026-06-23 08:04:04 +00:00
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
new Chart(document.getElementById('requestChart'), {{
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: {{
|
|
|
|
|
|
labels: {chart_labels},
|
|
|
|
|
|
datasets: [{{
|
|
|
|
|
|
label: '总请求',
|
|
|
|
|
|
data: {chart_requests},
|
|
|
|
|
|
borderColor: '#60a5fa',
|
|
|
|
|
|
backgroundColor: 'rgba(96,165,250,0.1)',
|
|
|
|
|
|
fill: true, tension: 0.3, pointRadius: 1,
|
|
|
|
|
|
}}, {{
|
|
|
|
|
|
label: '成功',
|
|
|
|
|
|
data: {chart_success},
|
|
|
|
|
|
borderColor: '#4ade80',
|
|
|
|
|
|
backgroundColor: 'rgba(74,222,128,0.1)',
|
|
|
|
|
|
fill: true, tension: 0.3, pointRadius: 1,
|
|
|
|
|
|
}}]
|
|
|
|
|
|
}},
|
|
|
|
|
|
options: {{
|
|
|
|
|
|
responsive: true, maintainAspectRatio: false,
|
|
|
|
|
|
plugins: {{ legend: {{ labels: {{ color: '#ccc' }} }} }},
|
|
|
|
|
|
scales: {{
|
|
|
|
|
|
x: {{ ticks: {{ color: '#888', maxTicksLimit: 12 }}, grid: {{ color: '#2a2a40' }} }},
|
|
|
|
|
|
y: {{ beginAtZero: true, ticks: {{ color: '#888' }}, grid: {{ color: '#2a2a40' }} }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
}});
|
|
|
|
|
|
new Chart(document.getElementById('latencyChart'), {{
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
data: {{
|
|
|
|
|
|
labels: {chart_labels},
|
|
|
|
|
|
datasets: [{{
|
|
|
|
|
|
label: '平均延迟 (ms)',
|
|
|
|
|
|
data: {chart_elapsed},
|
|
|
|
|
|
backgroundColor: 'rgba(167,139,250,0.5)',
|
|
|
|
|
|
borderColor: '#a78bfa', borderWidth: 1, borderRadius: 3,
|
|
|
|
|
|
}}]
|
|
|
|
|
|
}},
|
|
|
|
|
|
options: {{
|
|
|
|
|
|
responsive: true, maintainAspectRatio: false,
|
|
|
|
|
|
plugins: {{ legend: {{ labels: {{ color: '#ccc' }} }} }},
|
|
|
|
|
|
scales: {{
|
|
|
|
|
|
x: {{ ticks: {{ color: '#888', maxTicksLimit: 12 }}, grid: {{ color: '#2a2a40' }} }},
|
|
|
|
|
|
y: {{ beginAtZero: true, ticks: {{ color: '#888' }}, grid: {{ color: '#2a2a40' }} }}
|
|
|
|
|
|
}}
|
|
|
|
|
|
}}
|
|
|
|
|
|
}});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Metrics ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/metrics")
|
|
|
|
|
|
async def metrics():
|
|
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
cache = get_cache()
|
|
|
|
|
|
|
|
|
|
|
|
stats = collector.get_overview()
|
|
|
|
|
|
proxy_stats = pool.get_stats()
|
|
|
|
|
|
cache_stats = cache.get_stats()
|
|
|
|
|
|
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
"# HELP ao3_mirror_requests_total Total proxy requests",
|
|
|
|
|
|
"# TYPE ao3_mirror_requests_total counter",
|
|
|
|
|
|
f'ao3_mirror_requests_total {stats["total_requests"]}',
|
|
|
|
|
|
"# HELP ao3_mirror_successful_requests Successful requests",
|
|
|
|
|
|
"# TYPE ao3_mirror_successful_requests counter",
|
|
|
|
|
|
f'ao3_mirror_successful_requests {stats["successful"]}',
|
|
|
|
|
|
"# HELP ao3_mirror_failed_requests Failed requests",
|
|
|
|
|
|
"# TYPE ao3_mirror_failed_requests counter",
|
|
|
|
|
|
f'ao3_mirror_failed_requests {stats["failed"]}',
|
|
|
|
|
|
"# HELP ao3_mirror_cache_hits Cache hit count",
|
|
|
|
|
|
"# TYPE ao3_mirror_cache_hits counter",
|
|
|
|
|
|
f'ao3_mirror_cache_hits {stats["cached"]}',
|
|
|
|
|
|
"# HELP ao3_mirror_avg_elapsed_ms Average response time",
|
|
|
|
|
|
"# TYPE ao3_mirror_avg_elapsed_ms gauge",
|
|
|
|
|
|
f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}',
|
|
|
|
|
|
"# HELP ao3_mirror_proxy_pool Proxy pool status",
|
|
|
|
|
|
"# TYPE ao3_mirror_proxy_pool gauge",
|
2026-06-30 14:03:48 +00:00
|
|
|
|
f'ao3_mirror_proxy_total {proxy_stats["total"]}',
|
|
|
|
|
|
f'ao3_mirror_proxy_healthy {proxy_stats["healthy"]}',
|
|
|
|
|
|
f'ao3_mirror_proxy_unstable {proxy_stats["unstable"]}',
|
|
|
|
|
|
f'ao3_mirror_proxy_blocked {proxy_stats["blocked"]}',
|
|
|
|
|
|
f'ao3_mirror_proxy_probing {proxy_stats["probing"]}',
|
2026-06-23 08:04:04 +00:00
|
|
|
|
f'ao3_mirror_proxy_with_cookies {proxy_stats["with_cookies"]}',
|
2026-06-30 14:03:48 +00:00
|
|
|
|
f'ao3_mirror_proxy_inflight {proxy_stats["total_inflight"]}',
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"# HELP ao3_mirror_cache_size Current cache size",
|
|
|
|
|
|
"# TYPE ao3_mirror_cache_size gauge",
|
|
|
|
|
|
f'ao3_mirror_cache_size {cache_stats["size"]}',
|
|
|
|
|
|
]
|
|
|
|
|
|
return PlainTextResponse("\n".join(lines))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── Proxy handler (v5) ──────────────────────────────────────────────────
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
|
|
|
|
|
|
async def proxy_handler(request: Request, path: str):
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"""Main proxy handler — sticky sessions + P2C selection + semaphore-limited."""
|
2026-06-23 08:04:04 +00:00
|
|
|
|
start_time = time.time()
|
|
|
|
|
|
client_ip = get_client_ip(request)
|
|
|
|
|
|
|
|
|
|
|
|
if path == "" or path == "/":
|
|
|
|
|
|
path = ""
|
|
|
|
|
|
|
|
|
|
|
|
full_path = f"/{path}" if path else "/"
|
2026-06-30 14:03:48 +00:00
|
|
|
|
if full_path in LOCAL_PATHS or full_path.startswith("/sw"):
|
2026-06-23 08:04:04 +00:00
|
|
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Remap /__cf/ → /cdn-cgi/ (CF challenge paths bypass our CDN)
|
|
|
|
|
|
if full_path.startswith("/__cf/"):
|
|
|
|
|
|
path = "cdn-cgi" + path[4:]
|
|
|
|
|
|
full_path = "/" + path
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
if request.method == "OPTIONS":
|
|
|
|
|
|
resp = Response()
|
|
|
|
|
|
add_cors(resp)
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
query_string = request.url.query
|
|
|
|
|
|
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
|
|
|
|
|
|
|
|
|
|
|
|
# ── v4: Challenge token check ────────────────────────────────────────
|
2026-06-23 08:04:04 +00:00
|
|
|
|
cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token")
|
|
|
|
|
|
if cf_token:
|
|
|
|
|
|
challenge_entry = _get_challenge(cf_token)
|
|
|
|
|
|
if challenge_entry:
|
|
|
|
|
|
orig_method, orig_url, orig_headers, orig_body, orig_cookies, proxy_host, _ = challenge_entry
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.info(f"Challenge resolution: using proxy {proxy_host}")
|
|
|
|
|
|
challenge_proxy_obj = pool.get_by_addr(proxy_host)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
result = await fetch_url(
|
|
|
|
|
|
url=orig_url, method=orig_method,
|
2026-06-30 14:03:48 +00:00
|
|
|
|
proxy=challenge_proxy_obj,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
headers=dict(request.headers),
|
|
|
|
|
|
body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None,
|
|
|
|
|
|
cookies=orig_cookies,
|
|
|
|
|
|
is_api="/api/" in orig_url,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result["success"]:
|
|
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
|
|
rewritten_body = rewrite_body(result["body"],
|
2026-06-30 14:03:48 +00:00
|
|
|
|
result["headers"].get("Content-Type", result["headers"].get("content-type", "")))
|
|
|
|
|
|
content_type = result["headers"].get("Content-Type", result["headers"].get("content-type", ""))
|
|
|
|
|
|
if "text/html" in content_type:
|
|
|
|
|
|
rewritten_body = inject_service_worker(rewritten_body)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
rewritten_headers = rewrite_response_headers(result["headers"])
|
|
|
|
|
|
final_headers = filter_response_headers(rewritten_headers)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
final_headers.pop("Set-Cookie", None)
|
|
|
|
|
|
final_headers.pop("set-cookie", None)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
final_headers["X-Cache"] = "MISS"
|
|
|
|
|
|
final_headers["X-CF-Status"] = "solved"
|
|
|
|
|
|
|
|
|
|
|
|
resp = Response(content=rewritten_body, status_code=result["status"],
|
|
|
|
|
|
headers=final_headers)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
|
|
|
|
|
|
# Append ALL Set-Cookie from raw (preserves multi-value)
|
|
|
|
|
|
# Filter: strip AO3 CF cookies
|
|
|
|
|
|
SKIP_CF = {"__cf_bm", "_cfuvid"}
|
|
|
|
|
|
ao3_headers_raw = result.get("headers_raw", [])
|
|
|
|
|
|
if ao3_headers_raw:
|
|
|
|
|
|
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
|
|
|
|
|
|
for key, value in rewritten_raw:
|
|
|
|
|
|
if key.lower() == "set-cookie":
|
|
|
|
|
|
cookie_name = value.split("=")[0].split(";")[0].strip()
|
|
|
|
|
|
if cookie_name in SKIP_CF:
|
|
|
|
|
|
continue
|
|
|
|
|
|
resp.headers.append("Set-Cookie", value)
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
resp.delete_cookie("_cf_token", path="/")
|
|
|
|
|
|
|
|
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
collector.log_request(method=orig_method, path=full_path,
|
|
|
|
|
|
status=result["status"], elapsed=elapsed,
|
2026-06-30 14:03:48 +00:00
|
|
|
|
cached=False, proxy_host=proxy_host,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
client_ip=client_ip)
|
|
|
|
|
|
add_cors(resp)
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ── Static cache check (static assets only) ────────────────────
|
|
|
|
|
|
from cache import is_static_path
|
2026-06-23 08:04:04 +00:00
|
|
|
|
cache = get_cache()
|
2026-06-30 14:03:48 +00:00
|
|
|
|
if request.method == "GET" and is_static_path(full_path):
|
|
|
|
|
|
cached = cache.get(ao3_url)
|
|
|
|
|
|
if cached:
|
|
|
|
|
|
body, resp_headers, status = cached
|
|
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
collector.log_request(method=request.method, path=full_path, status=status,
|
|
|
|
|
|
elapsed=elapsed, cached=True, client_ip=client_ip)
|
|
|
|
|
|
return Response(content=body, status_code=status, headers=resp_headers)
|
|
|
|
|
|
|
|
|
|
|
|
# ── Prepare request ──────────────────────────────────────────────────
|
2026-06-23 08:04:04 +00:00
|
|
|
|
method = request.method
|
|
|
|
|
|
client_headers = dict(request.headers)
|
|
|
|
|
|
req_body = None
|
|
|
|
|
|
if method in ("POST", "PUT", "PATCH"):
|
|
|
|
|
|
req_body = await request.body()
|
|
|
|
|
|
|
|
|
|
|
|
cookies = {}
|
|
|
|
|
|
cookie_header = request.headers.get("cookie", "")
|
|
|
|
|
|
if cookie_header:
|
|
|
|
|
|
for pair in cookie_header.split(";"):
|
|
|
|
|
|
if "=" in pair:
|
|
|
|
|
|
k, v = pair.split("=", 1)
|
|
|
|
|
|
cookies[k.strip()] = v.strip()
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ── v5: Sticky proxy + P2C selection with semaphore ──────────────────
|
|
|
|
|
|
sticky_idx, had_sticky = get_sticky_proxy_idx(request)
|
|
|
|
|
|
final_proxy_idx = sticky_idx
|
|
|
|
|
|
tried_idxs: set[int] = set()
|
|
|
|
|
|
|
|
|
|
|
|
import random
|
|
|
|
|
|
is_fast = method in ("POST", "PUT", "PATCH") or "/users/" in full_path
|
|
|
|
|
|
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
|
|
|
|
|
|
|
|
|
|
|
|
# Try with sticky first, then P2C fallback
|
|
|
|
|
|
has_session = bool(cookies) # Stateful request: has user cookies
|
|
|
|
|
|
session_cookie_val = cookies.get("_otwarchive_session", "")[:80]
|
|
|
|
|
|
logger.info(f"Request {method} {full_path} | cookies={list(cookies.keys())} | session={session_cookie_val}... | has_session={has_session}")
|
|
|
|
|
|
async with pool.concurrency_limiter:
|
|
|
|
|
|
# First attempt: sticky proxy
|
|
|
|
|
|
result = None
|
|
|
|
|
|
sticky_proxy = pool.sticky_select(sticky_idx, tried_idxs)
|
|
|
|
|
|
if sticky_proxy:
|
|
|
|
|
|
sticky_proxy.inflight += 1
|
|
|
|
|
|
tried_idxs.add(sticky_proxy.idx)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await fetch_url(
|
|
|
|
|
|
url=ao3_url, method=method,
|
|
|
|
|
|
proxy=sticky_proxy,
|
|
|
|
|
|
headers=client_headers, body=req_body, cookies=cookies,
|
|
|
|
|
|
is_api="/api/" in full_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result["success"]:
|
|
|
|
|
|
final_proxy_idx = sticky_proxy.idx
|
|
|
|
|
|
sticky_proxy.mark_success(result.get("elapsed", 0))
|
|
|
|
|
|
sticky_proxy.inflight -= 1
|
|
|
|
|
|
elif result.get("is_challenge"):
|
|
|
|
|
|
sticky_proxy.mark_challenged()
|
|
|
|
|
|
sticky_proxy.inflight -= 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
status = result.get("status", 0)
|
|
|
|
|
|
if status in (403, 525):
|
|
|
|
|
|
sticky_proxy.mark_blocked()
|
|
|
|
|
|
else:
|
|
|
|
|
|
sticky_proxy.mark_failure(is_network=True)
|
|
|
|
|
|
sticky_proxy.inflight -= 1
|
|
|
|
|
|
result = None # Force retry
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
if sticky_proxy:
|
|
|
|
|
|
sticky_proxy.mark_failure(is_network=True)
|
|
|
|
|
|
sticky_proxy.inflight -= 1
|
|
|
|
|
|
result = None
|
|
|
|
|
|
|
|
|
|
|
|
# Retry loop with P2C — but NEVER for stateful requests
|
|
|
|
|
|
# If the sticky proxy failed on a request with cookies, switching
|
|
|
|
|
|
# proxies breaks CSRF/session affinity. Forward CF challenge instead.
|
|
|
|
|
|
if (not result or not result["success"]) and not has_session:
|
|
|
|
|
|
max_retries = 5
|
|
|
|
|
|
for attempt in range(max_retries):
|
|
|
|
|
|
if attempt > 0:
|
|
|
|
|
|
await _sleep(0.1 * (2 ** min(attempt, 3)))
|
|
|
|
|
|
proxy = pool.p2c_select(exclude_idxs=tried_idxs)
|
|
|
|
|
|
if not proxy:
|
|
|
|
|
|
break
|
|
|
|
|
|
proxy.inflight += 1
|
|
|
|
|
|
tried_idxs.add(proxy.idx)
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await fetch_url(
|
|
|
|
|
|
url=ao3_url, method=method,
|
|
|
|
|
|
proxy=proxy,
|
|
|
|
|
|
headers=client_headers, body=req_body, cookies=cookies,
|
|
|
|
|
|
is_api="/api/" in full_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result["success"]:
|
|
|
|
|
|
final_proxy_idx = proxy.idx
|
|
|
|
|
|
proxy.mark_success(result.get("elapsed", 0))
|
|
|
|
|
|
proxy.inflight -= 1
|
|
|
|
|
|
break
|
|
|
|
|
|
elif result.get("is_challenge"):
|
|
|
|
|
|
proxy.mark_challenged()
|
|
|
|
|
|
proxy.inflight -= 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
status = result.get("status", 0)
|
|
|
|
|
|
if status in (403, 525):
|
|
|
|
|
|
proxy.mark_blocked()
|
|
|
|
|
|
elif status > 0:
|
|
|
|
|
|
proxy.mark_failure(is_network=False)
|
|
|
|
|
|
else:
|
|
|
|
|
|
proxy.mark_failure(is_network=True)
|
|
|
|
|
|
proxy.inflight -= 1
|
|
|
|
|
|
result = None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
proxy.mark_failure(is_network=True)
|
|
|
|
|
|
proxy.inflight -= 1
|
|
|
|
|
|
result = None
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ── All proxies exhausted — outage ───────────────────────────────────
|
|
|
|
|
|
if not result or not result["success"]:
|
|
|
|
|
|
if result and result.get("is_challenge") and result.get("challenge_body"):
|
|
|
|
|
|
challenge_proxy = result.get("challenge_proxy", "unknown")
|
|
|
|
|
|
token = _make_challenge_token()
|
|
|
|
|
|
_store_challenge(token, method, ao3_url, client_headers,
|
|
|
|
|
|
req_body or b"", cookies, challenge_proxy)
|
|
|
|
|
|
rewritten_challenge = _rewrite_challenge_page(
|
|
|
|
|
|
result["challenge_body"], challenge_proxy, token
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.warning(f"CF Challenge via {challenge_proxy}, sending to browser")
|
|
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
collector.log_request(method=method, path=full_path, status=503,
|
|
|
|
|
|
elapsed=elapsed, cached=False,
|
|
|
|
|
|
proxy_host=challenge_proxy, client_ip=client_ip)
|
|
|
|
|
|
resp = HTMLResponse(
|
|
|
|
|
|
content=rewritten_challenge,
|
|
|
|
|
|
status_code=200, # 200 so browser renders and executes CF JS
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"X-CF-Challenge": "true",
|
|
|
|
|
|
"X-CF-Challenge-Proxy": challenge_proxy,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.set_cookie(
|
|
|
|
|
|
key="_cf_token", value=token,
|
|
|
|
|
|
path="/", max_age=CHALLENGE_TOKEN_TTL,
|
|
|
|
|
|
httponly=False, samesite="lax",
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.delete_cookie("cf_clearance", path="/")
|
|
|
|
|
|
add_cors(resp)
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
# Check if we hit outage threshold
|
|
|
|
|
|
if pool.pool_unavailable_ratio() >= OUTAGE_THRESHOLD:
|
|
|
|
|
|
logger.error(f"OUTAGE: {pool.pool_unavailable_ratio():.1%} proxies unavailable")
|
|
|
|
|
|
return HTMLResponse(
|
|
|
|
|
|
content=_outage_page(), status_code=503
|
|
|
|
|
|
)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.error(f"Failed: {ao3_url[:80]}: all retries exhausted")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
collector.log_request(method=method, path=full_path, status=502,
|
|
|
|
|
|
elapsed=elapsed, cached=False, client_ip=client_ip)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
return HTMLResponse(content=_transient_error_page(), status_code=502)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ── Success ──────────────────────────────────────────────────────────
|
2026-06-23 08:04:04 +00:00
|
|
|
|
ao3_status = result["status"]
|
|
|
|
|
|
ao3_headers = result.get("headers", {})
|
2026-06-30 14:03:48 +00:00
|
|
|
|
ao3_headers_raw = result.get("headers_raw", [])
|
2026-06-23 08:04:04 +00:00
|
|
|
|
raw_body = result.get("body", b"")
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
content_type = ao3_headers.get("Content-Type", ao3_headers.get("content-type", ""))
|
2026-06-23 08:04:04 +00:00
|
|
|
|
rewritten_body = rewrite_body(raw_body, content_type)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
|
|
|
|
|
|
# Inject Service Worker into HTML responses
|
|
|
|
|
|
if "text/html" in content_type:
|
|
|
|
|
|
rewritten_body = inject_service_worker(rewritten_body)
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
rewritten_headers = rewrite_response_headers(ao3_headers)
|
|
|
|
|
|
final_headers = filter_response_headers(rewritten_headers)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Remove Set-Cookie from dict — multi-value Set-Cookie from raw below
|
|
|
|
|
|
final_headers.pop("Set-Cookie", None)
|
|
|
|
|
|
final_headers.pop("set-cookie", None)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
final_headers["X-Cache"] = "MISS"
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Cache successful static GET responses (disk MD5 cache, like go3)
|
|
|
|
|
|
if method == "GET" and ao3_status == 200 and is_static_path(full_path):
|
|
|
|
|
|
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
# Handle redirects
|
|
|
|
|
|
if 300 <= ao3_status < 400 and "location" in rewritten_headers:
|
|
|
|
|
|
redirect_url = rewritten_headers["location"]
|
|
|
|
|
|
if AO3_BASE in redirect_url:
|
|
|
|
|
|
redirect_url = redirect_url.replace(AO3_BASE, "").replace("http://", "https://")
|
|
|
|
|
|
return RedirectResponse(url=redirect_url, status_code=ao3_status)
|
|
|
|
|
|
|
|
|
|
|
|
collector = get_stats_collector()
|
|
|
|
|
|
collector.log_request(method=method, path=full_path, status=ao3_status,
|
|
|
|
|
|
elapsed=elapsed, cached=False,
|
|
|
|
|
|
proxy_host=result.get("proxy_host"),
|
|
|
|
|
|
client_ip=client_ip)
|
|
|
|
|
|
|
|
|
|
|
|
resp = Response(content=rewritten_body, status_code=ao3_status, headers=final_headers)
|
2026-06-30 14:03:48 +00:00
|
|
|
|
|
|
|
|
|
|
# Append ALL Set-Cookie headers from raw (preserves multi-value — pitfall #18a)
|
|
|
|
|
|
# Filter: strip AO3 CF cookies — our own CF edge handles these
|
|
|
|
|
|
SKIP_COOKIES = {"__cf_bm", "_cfuvid"}
|
|
|
|
|
|
if ao3_headers_raw:
|
|
|
|
|
|
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
|
|
|
|
|
|
for key, value in rewritten_raw:
|
|
|
|
|
|
if key.lower() == "set-cookie":
|
|
|
|
|
|
cookie_name = value.split("=")[0].split(";")[0].strip()
|
|
|
|
|
|
if cookie_name in SKIP_COOKIES:
|
|
|
|
|
|
continue
|
|
|
|
|
|
logger.info(f"[SET_COOKIE_FWD] {value[:120]}...")
|
|
|
|
|
|
resp.headers.append("Set-Cookie", value)
|
|
|
|
|
|
|
|
|
|
|
|
# Set sticky session cookie
|
|
|
|
|
|
resp.set_cookie(
|
|
|
|
|
|
key=STICKY_COOKIE_NAME,
|
|
|
|
|
|
value=str(final_proxy_idx),
|
|
|
|
|
|
path="/",
|
|
|
|
|
|
httponly=True,
|
|
|
|
|
|
samesite="lax",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
add_cors(resp)
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── Error pages ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _outage_page() -> str:
|
|
|
|
|
|
return """<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head><meta charset="UTF-8"><title>AO3 Mirror - 暂时不可用</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
body{font-family:sans-serif;text-align:center;padding:50px;background:#0f0f1a;color:#e0e0e0}
|
|
|
|
|
|
h1{color:#990000}
|
|
|
|
|
|
.card{background:#1a1a2e;border-radius:10px;padding:30px;max-width:500px;margin:30px auto;border:1px solid #2a2a40}
|
|
|
|
|
|
.btn{display:inline-block;padding:10px 24px;background:#990000;color:#fff;text-decoration:none;border-radius:6px;margin-top:15px}
|
|
|
|
|
|
</style></head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h1>⚠️ AO3 暂时不可用</h1>
|
|
|
|
|
|
<p>所有代理目前都无法连接 AO3。可能 AO3 正在维护或大规模封锁。</p>
|
|
|
|
|
|
<p style="color:#888">请稍后重试。如果持续出现,请切换到数据流量。</p>
|
|
|
|
|
|
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _transient_error_page() -> str:
|
|
|
|
|
|
return """<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head><meta charset="UTF-8"><title>AO3 Mirror - 连接失败</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
body{font-family:sans-serif;text-align:center;padding:50px;background:#0f0f1a;color:#e0e0e0}
|
|
|
|
|
|
h1{color:#990000}
|
|
|
|
|
|
.card{background:#1a1a2e;border-radius:10px;padding:30px;max-width:500px;margin:30px auto;border:1px solid #2a2a40}
|
|
|
|
|
|
.btn{display:inline-block;padding:10px 24px;background:#990000;color:#fff;text-decoration:none;border-radius:6px;margin-top:15px}
|
|
|
|
|
|
</style></head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h1>🔄 正在尝试连接 AO3</h1>
|
|
|
|
|
|
<p>镜像站正在通过代理重新连接 AO3 服务器。</p>
|
|
|
|
|
|
<p style="color:#888">请稍后刷新页面重试。</p>
|
|
|
|
|
|
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Helper ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async def _sleep(seconds: float):
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
await asyncio.sleep(seconds)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
# ─── Startup / Shutdown ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
@app.on_event("startup")
|
|
|
|
|
|
async def startup():
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.info("AO3 Mirror backend v5 starting up...")
|
|
|
|
|
|
pool = get_proxy_pool()
|
2026-06-23 08:04:04 +00:00
|
|
|
|
get_cache()
|
|
|
|
|
|
get_stats_collector()
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.info(f"AO3 Mirror backend v5 started ({len(pool.proxies)} proxies, "
|
|
|
|
|
|
f"P2C selection, sticky sessions, SW deploy)")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
|
|
|
|
async def shutdown():
|
|
|
|
|
|
logger.info("AO3 Mirror backend shutting down...")
|
|
|
|
|
|
pool = get_proxy_pool()
|
|
|
|
|
|
await pool.close_all()
|
|
|
|
|
|
logger.info("AO3 Mirror backend stopped")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Entry point ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
port = int(os.environ.get("PORT", "8080"))
|
|
|
|
|
|
workers = int(os.environ.get("WORKERS", "4"))
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.info(f"Starting AO3 Mirror v5 on port {port} with {workers} workers")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
uvicorn.run(
|
|
|
|
|
|
"app:app",
|
|
|
|
|
|
host="127.0.0.1",
|
|
|
|
|
|
port=port,
|
|
|
|
|
|
workers=workers,
|
|
|
|
|
|
log_level="info",
|
|
|
|
|
|
timeout_keep_alive=30,
|
|
|
|
|
|
)
|