Files
agento3/ao3_fetcher.py
akiba 122c408fff fix: resolve 3 security audit issues
#3: Stored XSS in stats dashboard - escape p[path] with html.escape()
#4: Caddy timeout race - increase read/write_timeout 30s -> 60s
#5: Missing CSP header - add Content-Security-Policy to Caddyfile
2026-06-30 14:03:48 +00:00

277 lines
10 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 内容抓取器 v5 — 单次代理请求 + CF 挑战检测
v5 vs v4:
- 去掉内部重试循环(重试逻辑迁移到 app.py 的 P2C 选择中)
- 简化 API直接接受 ProxySession 对象
- 保留 CF 挑战检测 + 状态机反馈
"""
import asyncio
import logging
import time
from typing import Optional
from proxy_pool import ProxySession
logger = logging.getLogger("ao3-fetcher")
# ─── CF Challenge Detection ───────────────────────────────────────────────
CF_CHALLENGE_MARKERS = [
b'/cdn-cgi/challenge-platform',
b'cf-challenge-running',
b'cf-browser-verification',
b'window._cf_chl_opt',
b'challenge-platform',
b'cf-turnstile',
b'cf_chl_',
b'Checking your browser',
b'Just a moment...',
]
def is_cf_challenge(status: int, body: bytes, headers: dict) -> bool:
"""Detect if response is a Cloudflare challenge page."""
if status not in (403, 503, 429):
return False
server = headers.get("Server", headers.get("server", ""))
if "cloudflare" not in server.lower():
for marker in CF_CHALLENGE_MARKERS:
if marker in body:
return True
return False
for marker in CF_CHALLENGE_MARKERS:
if marker in body:
return True
if status in (403, 503):
return True
return False
# ─── Headers ──────────────────────────────────────────────────────────────
CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
"Sec-Ch-Ua": '"Not A(Brand";v="99", "Google Chrome";v="120", "Chromium";v="120"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Windows"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"DNT": "1",
}
API_HEADERS = {
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
}
# Timeout config
FAST_REQUEST_TIMEOUT = 8
NORMAL_REQUEST_TIMEOUT = 15
FAST_PATHS = {
"/users/login", "/users/sign_up", "/users/new",
"/invitation_requests", "/token_dispenser.json", "/user_sessions",
}
def _is_fast_path(url: str) -> bool:
for fp in FAST_PATHS:
if fp in url:
return True
if "/users/" in url or "/user_sessions" in url:
return True
return False
def _merge_cookies(proxy_cookies: str, user_cookies: str) -> str:
if not proxy_cookies and not user_cookies:
return ""
if not proxy_cookies:
return user_cookies
if not user_cookies:
return proxy_cookies
return f"{proxy_cookies}; {user_cookies}"
# ─── Main fetch function ──────────────────────────────────────────────────
async def fetch_url(
url: str,
proxy: Optional[ProxySession] = None,
method: str = "GET",
headers: Optional[dict] = None,
body: Optional[bytes] = None,
cookies: Optional[dict] = None,
is_api: bool = False,
) -> dict:
"""
Single async fetch through a specific proxy. No internal retry loop.
Args:
url: Target AO3 URL
proxy: ProxySession to use (must already be selected by app.py)
method: HTTP method
headers: Request headers
body: Request body (POST/PUT/PATCH)
cookies: User cookies dict
is_api: Use API headers instead of Chrome headers
Returns:
dict with success/status/headers/body/cookies/elapsed/is_challenge etc.
"""
if proxy is None:
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": "No proxy provided",
"elapsed": 0, "proxy_host": None,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
host_port = proxy.host_port
start_time = time.time()
base_headers = API_HEADERS.copy() if is_api else CHROME_HEADERS.copy()
if headers:
for h in ["Cookie", "Content-Type", "X-Requested-With",
"Accept", "X-CSRF-Token", "Authorization"]:
if h in headers:
base_headers[h] = headers[h]
# Rewrite Origin and Referer: mirror domain → AO3 domain
# AO3 CSRF checks Origin against archiveofourown.org
if "Origin" in headers:
origin = headers["Origin"]
origin = origin.replace("agento3.miscs.dev", "archiveofourown.org")
base_headers["Origin"] = origin
if "Referer" in headers:
referer = headers["Referer"]
referer = referer.replace("agento3.miscs.dev", "archiveofourown.org")
base_headers["Referer"] = referer
# Filter out OUR cookies — proxy is transparent, only AO3 cookies should reach AO3
FORWARD_BLOCKED = {"ao3_sessid_proxy", "__cf_bm", "_cfuvid", "_cf_token"}
user_cookie_str = ""
if cookies:
user_cookie_str = "; ".join(
f"{k}={v}" for k, v in cookies.items()
if k not in FORWARD_BLOCKED
)
is_fast = _is_fast_path(url) or method in ("POST", "PUT", "PATCH")
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
try:
session = await proxy.get_session()
request_headers = base_headers.copy()
# Inject proxy-level cookies (cf_clearance)
proxy_cookie_str = proxy.get_cookie_header()
cookie_str = _merge_cookies(proxy_cookie_str, user_cookie_str)
if cookie_str:
request_headers["Cookie"] = cookie_str
# Execute request
request_cookie_preview = cookie_str[:500] + "..." if len(cookie_str) > 500 else cookie_str
logger.info(f"[FETCH] {method} {url[:80]} full_cookie={request_cookie_preview}")
if method == "GET":
resp = await session.get(url, timeout=request_timeout, headers=request_headers)
elif method == "POST":
resp = await session.post(url, data=body, timeout=request_timeout, headers=request_headers)
elif method == "HEAD":
resp = await session.head(url, timeout=request_timeout, headers=request_headers)
else:
resp = await session.request(method, url, data=body, timeout=request_timeout,
headers=request_headers)
elapsed = time.time() - start_time
status = resp.status_code
resp_body = resp.content
resp_headers = dict(resp.headers) # For Content-Type/Location lookups
resp_headers_raw = list(resp.headers.multi_items()) # Preserves ALL Set-Cookie values
# Debug: log response Set-Cookie count
set_cookie_count = sum(1 for k, v in resp_headers_raw if k.lower() == "set-cookie")
resp_status = resp.status_code
body_preview = resp_body[:200].decode('utf-8', errors='replace')
logger.info(f"[FETCH_RESP] {method} {url[:60]} status={resp_status} set_cookies={set_cookie_count} body={body_preview}")
resp_cookies = {}
if hasattr(resp, "cookies"):
for k, v in resp.cookies.items():
resp_cookies[k] = v
# CF challenge detection
if is_cf_challenge(status, resp_body, resp_headers):
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status}")
return {
"status": status, "headers": resp_headers, "body": resp_body,
"headers_raw": resp_headers_raw,
"cookies": resp_cookies,
"success": False, "error": f"CF_CHALLENGE_{status}",
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": True, "challenge_body": resp_body,
"challenge_proxy": host_port,
}
# Success
if 200 <= status < 500:
sc_from_dict = resp_headers.get("Set-Cookie", resp_headers.get("set-cookie", "NONE"))
logger.info(f"[SAVE_COOKIES] proxy={host_port} set_cookie_dict={sc_from_dict[:120]}...")
proxy.save_cookies(resp_headers)
return {
"status": status, "headers": resp_headers, "body": resp_body,
"headers_raw": resp_headers_raw,
"cookies": resp_cookies,
"success": True,
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
# Error status
logger.warning(f"{host_port} -> {url[:60]}: {status}")
return {
"status": status, "headers": resp_headers, "body": resp_body,
"headers_raw": resp_headers_raw,
"cookies": resp_cookies,
"success": False, "error": f"HTTP_{status}",
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
except asyncio.TimeoutError:
elapsed = time.time() - start_time
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]}")
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": "TIMEOUT",
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
except Exception as e:
elapsed = time.time() - start_time
err_str = str(e)[:120]
logger.debug(f"ERROR: {host_port} -> {err_str}")
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": err_str,
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}