Files
agento3/app.py
akiba 085cc7a140 AO3 Mirror v4 — initial commit
Cookie-aware proxy pool with CF challenge solving
- Tiered proxy pool (fast 50 + main 676)
- Per-proxy cf_clearance cookie persistence
- CF challenge detection + user-browser solving
- Safari + Chrome TLS fingerprint rotation
- Async FastAPI backend with LRU cache
- Passive daemon with systemd supervision
- Stats dashboard + Prometheus metrics
2026-06-23 08:04:04 +00:00

673 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AO3 反代后端 v4 — Cookie 感知 + CF 挑战用户浏览器求解
v4 vs v3:
- Challenge token 映射:用户浏览器求解 CF 挑战时,保证同一 proxy 亲和性
- 挑战页面透传:所有代理都遇到 CF 挑战时,把挑战页发给用户浏览器求解
- Cookie 流:成功响应自动保存 proxy cookie后续请求自动携带
- 统计面板展示 cookie-aware 代理数
"""
import hashlib
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__)))
from proxy_pool import get_proxy_pool
from ao3_fetcher import fetch_url, is_cf_challenge
from url_rewriter import rewrite_body, rewrite_response_headers, needs_rewrite, MIRROR_DOMAIN
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
LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"}
# ─── Challenge Token Map (v4) ──────────────────────────────────────────────
# Maps challenge_token → (method, ao3_url, headers, body, cookies, proxy_host, expires)
_challenge_map: dict[str, tuple] = {}
_challenge_lock = threading.Lock()
CHALLENGE_TOKEN_TTL = 120 # 2 minutes for the user's browser to solve the challenge
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:
# Clean expired tokens
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:
del _challenge_map[token] # expired
return None
# ─── App ───────────────────────────────────────────────────────────────────
app = FastAPI(
title="AO3 Mirror",
description="AO3 reverse proxy mirror for Chinese users",
version="4.0.0",
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:
"""Rewrite CF challenge page so all URLs go through mirror, and tag with challenge token."""
import re
# Basic AO3 domain rewrite
body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode())
# Rewrite /cdn-cgi/ challenge endpoints to go through mirror
# These are CF's internal challenge platform URLs
body = body.replace(
b"/cdn-cgi/challenge-platform",
f"/cdn-cgi/challenge-platform".encode()
)
# Inject a marker meta tag so we can detect challenge pages coming back
marker = f'<meta name="cf-challenge-proxy" content="{proxy_host}">'.encode()
marker_cookie = (
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'
).encode()
body = body.replace(b"<head>", b"<head>" + marker + marker_cookie, 1)
if b"<head>" not in body:
body = b"<head>" + marker + marker_cookie + b"</head>" + body
return body
# ─── 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
result["X-Proxy"] = "AO3-Mirror/4.0"
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()
resp = JSONResponse({
"status": "ok",
"timestamp": time.time(),
"version": "4.0.0",
"proxy_pool": stats,
})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
resp.headers["Pragma"] = "no-cache"
return resp
@app.get("/robots.txt")
async def robots():
return PlainTextResponse(
"User-agent: *\nDisallow: /stats\nDisallow: /health\n"
)
# ─── 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">
<title>AO3 Mirror - 统计面板 v4</title>
<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; }}
@media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="container">
<h1>🛡 AO3 Mirror 统计面板 v4</h1>
<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">
<div class="label">代理池</div>
<div class="value green">{proxy_stats['alive']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">可用: {proxy_stats['available']} | 受损: {proxy_stats['dead']} | Banned: {proxy_stats['banned']}</div>
</div>
<div class="card">
<div class="label">Cookie 代理</div>
<div class="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['alive']}</span></div>
<div class="sub">已持有 cf_clearance</div>
</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>
{''.join(f'<tr><td>{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])}
</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",
f'ao3_mirror_proxy_alive {proxy_stats["alive"]}',
f'ao3_mirror_proxy_dead {proxy_stats["dead"]}',
f'ao3_mirror_proxy_banned {proxy_stats["banned"]}',
f'ao3_mirror_proxy_available {proxy_stats["available"]}',
f'ao3_mirror_proxy_with_cookies {proxy_stats["with_cookies"]}',
"# 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))
# ─── Proxy Core (v4) ──────────────────────────────────────────────────────
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
async def proxy_handler(request: Request, path: str):
"""Main proxy handler — with cookie-aware fetching and CF challenge solving."""
start_time = time.time()
client_ip = get_client_ip(request)
if path == "" or path == "/":
path = ""
full_path = f"/{path}" if path else "/"
if full_path in LOCAL_PATHS or full_path.startswith("/stats") or full_path.startswith("/health"):
return JSONResponse({"error": "Not found"}, status_code=404)
if request.method == "OPTIONS":
resp = Response()
add_cors(resp)
return resp
# ── v4: Challenge token check ──────────────────────────────────────────
# If this request has a _cf_token cookie, it's part of a challenge resolution flow
cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token")
preferred_proxy = None
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
preferred_proxy = proxy_host
logger.info(f"Challenge resolution: using proxy {proxy_host} for {orig_url[:60]}")
# Re-fetch the original request with the challenge proxy
result = await fetch_url(
url=orig_url, method=orig_method,
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,
preferred_proxy=preferred_proxy,
)
if result["success"]:
# Challenge solved! Proxy now has cf_clearance. Return content.
elapsed = time.time() - start_time
rewritten_body = rewrite_body(result["body"],
result["headers"].get("Content-Type", ""))
rewritten_headers = rewrite_response_headers(result["headers"])
final_headers = filter_response_headers(rewritten_headers)
final_headers["X-Cache"] = "MISS"
final_headers["X-CF-Status"] = "solved"
# Set cf_clearance as a cookie on the mirror domain too
# so subsequent requests benefit
resp = Response(content=rewritten_body, status_code=result["status"],
headers=final_headers)
# Forward any Set-Cookie from AO3 (includes cf_clearance)
for k, v in result["headers"].items():
if k.lower() == "set-cookie":
# Rewrite domain
v_rewritten = v.replace("domain=archiveofourown.org",
f"domain={MIRROR_DOMAIN}")
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
f"domain=.{MIRROR_DOMAIN}")
resp.headers.add("Set-Cookie", v_rewritten)
# Clean up challenge token cookie
resp.delete_cookie("_cf_token", path="/")
collector = get_stats_collector()
collector.log_request(method=orig_method, path=full_path,
status=result["status"], elapsed=elapsed,
cached=False, proxy_host=preferred_proxy,
client_ip=client_ip)
add_cors(resp)
return resp
# ── Normal request flow ────────────────────────────────────────────────
query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# Check cache
cache = get_cache()
cached = cache.get(ao3_url, dict(request.headers))
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)
headers = filter_response_headers(resp_headers)
headers["X-Cache"] = "HIT"
return Response(content=body, status_code=status, headers=headers)
# Prepare request
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()
# Forward to AO3
result = await fetch_url(
url=ao3_url, method=method,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path or full_path.startswith("/api/"),
preferred_proxy=preferred_proxy,
)
elapsed = time.time() - start_time
# ── v4: CF Challenge handling ──────────────────────────────────────────
if result.get("is_challenge") and result.get("challenge_body"):
challenge_proxy = result.get("challenge_proxy", "unknown")
challenge_body = result["challenge_body"]
# Generate challenge token for proxy affinity
token = _make_challenge_token()
_store_challenge(token, method, ao3_url, client_headers,
req_body or b"", cookies, challenge_proxy)
# Rewrite challenge page for user's browser
rewritten_challenge = _rewrite_challenge_page(challenge_body, challenge_proxy, token)
logger.warning(
f"CF Challenge detected via {challenge_proxy} for {ao3_url[:80]}. "
f"Sending challenge to user browser (token={token[:8]}...)"
)
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)
# Return challenge page with 503 status + token cookie
resp = HTMLResponse(
content=rewritten_challenge,
status_code=503,
headers={
"X-CF-Challenge": "true",
"X-CF-Challenge-Proxy": challenge_proxy,
"Retry-After": "5",
},
)
resp.set_cookie(
key="_cf_token", value=token,
path="/", max_age=CHALLENGE_TOKEN_TTL,
httponly=False, # JS needs to read it
samesite="lax",
)
resp.delete_cookie("cf_clearance", path="/") # Clear stale clearance
add_cors(resp)
return resp
# ── Standard failure ───────────────────────────────────────────────────
if not result["success"]:
logger.error(f"Failed to fetch {ao3_url[:80]}: {result.get('error', 'unknown')}")
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=502,
elapsed=elapsed, cached=False, client_ip=client_ip)
error_html = f"""<!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: white; 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; font-size: 0.9em;">请稍后刷新页面重试。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
return HTMLResponse(content=error_html, status_code=502)
# ── Success ────────────────────────────────────────────────────────────
ao3_status = result["status"]
ao3_headers = result.get("headers", {})
raw_body = result.get("body", b"")
content_type = ao3_headers.get("Content-Type", "")
rewritten_body = rewrite_body(raw_body, content_type)
rewritten_headers = rewrite_response_headers(ao3_headers)
final_headers = filter_response_headers(rewritten_headers)
final_headers["X-Cache"] = "MISS"
# Cache successful GET responses
if method == "GET" and 200 <= ao3_status < 400:
ttl = get_ttl_for_path(full_path)
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status, ttl=ttl)
# 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)
# Forward Set-Cookie from AO3 (user session cookies + cf_clearance)
for k, v in ao3_headers.items():
if k.lower() == "set-cookie":
v_rewritten = v.replace("domain=archiveofourown.org",
f"domain={MIRROR_DOMAIN}")
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
f"domain=.{MIRROR_DOMAIN}")
resp.headers.add("Set-Cookie", v_rewritten)
add_cors(resp)
return resp
# ─── Startup / Shutdown ────────────────────────────────────────────────────
@app.on_event("startup")
async def startup():
logger.info("AO3 Mirror backend v4 starting up...")
get_proxy_pool()
get_cache()
get_stats_collector()
logger.info("AO3 Mirror backend v4 started (cookie-aware + CF challenge solving)")
@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"))
logger.info(f"Starting AO3 Mirror v4 on port {port} with {workers} workers")
uvicorn.run(
"app:app",
host="127.0.0.1",
port=port,
workers=workers,
log_level="info",
timeout_keep_alive=30,
)