2026-06-23 08:04:04 +00:00
|
|
|
|
"""
|
2026-06-30 14:03:48 +00:00
|
|
|
|
异步 AO3 内容抓取器 v5 — 单次代理请求 + CF 挑战检测
|
|
|
|
|
|
|
|
|
|
|
|
v5 vs v4:
|
|
|
|
|
|
- 去掉内部重试循环(重试逻辑迁移到 app.py 的 P2C 选择中)
|
|
|
|
|
|
- 简化 API:直接接受 ProxySession 对象
|
|
|
|
|
|
- 保留 CF 挑战检测 + 状态机反馈
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import time
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
from proxy_pool import ProxySession
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"""Detect if response is a Cloudflare challenge page."""
|
2026-06-23 08:04:04 +00:00
|
|
|
|
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",
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"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"
|
|
|
|
|
|
),
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"DNT": "1",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
API_HEADERS = {
|
|
|
|
|
|
"Accept": "*/*",
|
|
|
|
|
|
"Accept-Language": "en-US,en;q=0.9",
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"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"
|
|
|
|
|
|
),
|
2026-06-23 08:04:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Timeout config
|
|
|
|
|
|
FAST_REQUEST_TIMEOUT = 8
|
2026-06-23 08:04:04 +00:00
|
|
|
|
NORMAL_REQUEST_TIMEOUT = 15
|
|
|
|
|
|
|
|
|
|
|
|
FAST_PATHS = {
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"/users/login", "/users/sign_up", "/users/new",
|
|
|
|
|
|
"/invitation_requests", "/token_dispenser.json", "/user_sessions",
|
2026-06-23 08:04:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# ─── Main fetch function ──────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
async def fetch_url(
|
|
|
|
|
|
url: str,
|
2026-06-30 14:03:48 +00:00
|
|
|
|
proxy: Optional[ProxySession] = None,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
method: str = "GET",
|
|
|
|
|
|
headers: Optional[dict] = None,
|
|
|
|
|
|
body: Optional[bytes] = None,
|
|
|
|
|
|
cookies: Optional[dict] = None,
|
|
|
|
|
|
is_api: bool = False,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""
|
2026-06-30 14:03:48 +00:00
|
|
|
|
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.
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"""
|
2026-06-30 14:03:48 +00:00
|
|
|
|
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()
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
base_headers = API_HEADERS.copy() if is_api else CHROME_HEADERS.copy()
|
2026-06-23 08:04:04 +00:00
|
|
|
|
if headers:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
for h in ["Cookie", "Content-Type", "X-Requested-With",
|
|
|
|
|
|
"Accept", "X-CSRF-Token", "Authorization"]:
|
2026-06-23 08:04:04 +00:00
|
|
|
|
if h in headers:
|
|
|
|
|
|
base_headers[h] = headers[h]
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# 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"}
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
user_cookie_str = ""
|
|
|
|
|
|
if cookies:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
user_cookie_str = "; ".join(
|
|
|
|
|
|
f"{k}={v}" for k, v in cookies.items()
|
|
|
|
|
|
if k not in FORWARD_BLOCKED
|
|
|
|
|
|
)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Inject proxy-level cookies (cf_clearance)
|
2026-06-23 08:04:04 +00:00
|
|
|
|
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
|
2026-06-30 14:03:48 +00:00
|
|
|
|
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}")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
elapsed = time.time() - start_time
|
2026-06-23 08:04:04 +00:00
|
|
|
|
status = resp.status_code
|
|
|
|
|
|
resp_body = resp.content
|
2026-06-30 14:03:48 +00:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-06-23 08:04:04 +00:00
|
|
|
|
resp_cookies = {}
|
|
|
|
|
|
if hasattr(resp, "cookies"):
|
|
|
|
|
|
for k, v in resp.cookies.items():
|
|
|
|
|
|
resp_cookies[k] = v
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# CF challenge detection
|
2026-06-23 08:04:04 +00:00
|
|
|
|
if is_cf_challenge(status, resp_body, resp_headers):
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status}")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
return {
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"status": status, "headers": resp_headers, "body": resp_body,
|
|
|
|
|
|
"headers_raw": resp_headers_raw,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"cookies": resp_cookies,
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"success": False, "error": f"CF_CHALLENGE_{status}",
|
|
|
|
|
|
"elapsed": elapsed, "proxy_host": host_port,
|
|
|
|
|
|
"is_challenge": True, "challenge_body": resp_body,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"challenge_proxy": host_port,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Success
|
2026-06-23 08:04:04 +00:00
|
|
|
|
if 200 <= status < 500:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
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]}...")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
proxy.save_cookies(resp_headers)
|
|
|
|
|
|
return {
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"status": status, "headers": resp_headers, "body": resp_body,
|
|
|
|
|
|
"headers_raw": resp_headers_raw,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"cookies": resp_cookies,
|
|
|
|
|
|
"success": True,
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"elapsed": elapsed, "proxy_host": host_port,
|
|
|
|
|
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 14:03:48 +00:00
|
|
|
|
# Error status
|
|
|
|
|
|
logger.warning(f"{host_port} -> {url[:60]}: {status}")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
return {
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"status": status, "headers": resp_headers, "body": resp_body,
|
|
|
|
|
|
"headers_raw": resp_headers_raw,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"cookies": resp_cookies,
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"success": False, "error": f"HTTP_{status}",
|
|
|
|
|
|
"elapsed": elapsed, "proxy_host": host_port,
|
|
|
|
|
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
2026-06-23 08:04:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except asyncio.TimeoutError:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
|
|
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]}")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
return {
|
|
|
|
|
|
"status": 0, "headers": {}, "body": b"", "cookies": {},
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"headers_raw": [],
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"success": False, "error": "TIMEOUT",
|
|
|
|
|
|
"elapsed": elapsed, "proxy_host": host_port,
|
|
|
|
|
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-06-30 14:03:48 +00:00
|
|
|
|
elapsed = time.time() - start_time
|
2026-06-23 08:04:04 +00:00
|
|
|
|
err_str = str(e)[:120]
|
2026-06-30 14:03:48 +00:00
|
|
|
|
logger.debug(f"ERROR: {host_port} -> {err_str}")
|
2026-06-23 08:04:04 +00:00
|
|
|
|
return {
|
|
|
|
|
|
"status": 0, "headers": {}, "body": b"", "cookies": {},
|
2026-06-30 14:03:48 +00:00
|
|
|
|
"headers_raw": [],
|
2026-06-23 08:04:04 +00:00
|
|
|
|
"success": False, "error": err_str,
|
|
|
|
|
|
"elapsed": elapsed, "proxy_host": host_port,
|
|
|
|
|
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
|
|
|
|
|
}
|