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
This commit is contained in:
akiba
2026-06-30 14:03:48 +00:00
parent 085cc7a140
commit 122c408fff
32 changed files with 9066 additions and 859 deletions

119
AGENTS.md
View File

@@ -1,15 +1,49 @@
# AGENTS — AO3 Mirror # AGENTS — AO3 Mirror v5
This file is for AI agents (Hermes, Claude Code, Codex) working on the AO3 reverse proxy mirror. This file is for AI agents (Hermes, Claude Code, Codex) working on the AO3 reverse proxy mirror.
## Project Overview ## Project Overview
AO3 Mirror is a high-availability reverse proxy for archiveofourown.org, designed to restore access for Chinese users. It bypasses Cloudflare's bot protection using TLS fingerprint impersonation (curl_cffi), a tiered WebShare proxy pool (726 proxies, 72%+ CF bypass rate), cookie-aware session management, and user-browser CF challenge solving. AO3 Mirror v5 is a high-performance reverse proxy for archiveofourown.org, redesigned from the ground up with sticky sessions, Service Worker client-side caching, and a full 5000-proxy P2C pool — matching the go3 architecture with modern Python tooling.
- **Domain**: agento3.miscs.dev (behind Cloudflare CDN) - **Domain**: agento3.miscs.dev (behind Cloudflare CDN)
- **Target**: archiveofourown.org - **Target**: archiveofourown.org
- **Stack**: Python 3.11, FastAPI, uvicorn + uvloop + httptools, curl_cffi, Caddy - **Stack**: Python 3.11, FastAPI, uvicorn + uvloop + httptools, curl_cffi, Caddy
- **QPS**: 720 (cache) | 270 (direct proxy) - **Proxy Pool**: 5000 WebShare proxies, P2C selection, state machine (healthy/unstable/blocked/probing)
- **Client**: Service Worker for static cache + offline fallback
- **Architecture Reference**: https://tomorin.cv/AO3-CN/go3 (go3 v2 branch)
## Architecture v5
```
User Browser
│ ao3_sessid_proxy cookie = sticky proxy
├─ Service Worker (sw.js)
│ ├─ Cache API — static assets cached in browser
│ ├─ Navigation intercept — offline mirror picker
│ └─ Request passthrough — for non-static, non-nav
└─ HTTPS → Caddy → 2× uvicorn workers (8081-8082)
├─ Proxy Pool (5000, P2C selection)
│ ├─ State machine: healthy/unstable/blocked/probing
│ ├─ Concurrency limit: asyncio.Semaphore(400)
│ └─ Passive probing: 10s interval, 10 concurrency
├─ Sticky Session (ao3_sessid_proxy)
│ └─ User pinned to one proxy for session duration
├─ Service Worker Deploy (/sw.js, /sw-YYYYMMDD.js)
│ └─ HTML injection: auto-register SW on page load
├─ CF Challenge → User Browser
│ └─ All proxies blocked → rewrite challenge → browser solves
├─ Static Cache (server-side LRU + disk MD5)
└─ Local routes: /stats, /health, /metrics
```
## Build Commands ## Build Commands
@@ -26,85 +60,60 @@ sudo systemctl restart ao3-daemon
# Restart Caddy only # Restart Caddy only
sudo systemctl reload caddy sudo systemctl reload caddy
# Scan proxy pool (MUST run in foreground!) # Syntax check all Python files
cd /home/ubuntu/ao3-mirror && python3 scripts/scan_proxies_cffi.py
# Syntax check
python3 -m py_compile proxy_pool.py ao3_fetcher.py app.py cache.py stats.py url_rewriter.py python3 -m py_compile proxy_pool.py ao3_fetcher.py app.py cache.py stats.py url_rewriter.py
# Deploy Service Worker update
# 1. Edit static/sw.js
# 2. Update SERVICE_WORKER_DATE in app.py
# 3. Restart workers
``` ```
## Architecture ### Core Files (v5)
```
User → Cloudflare CDN (agento3.miscs.dev) → Caddy :443 (60s, round-robin)
→ 2× uvicorn workers (8081-8082) [cpu-pinned, uvloop]
→ Tiered Proxy Pool:
Fast (50): POST/login → 8s timeout, 1 retry
Main (676): GET/browse → 15s timeout, 2 retries
→ Cookie-aware sessions: per-proxy cf_clearance persistence
→ TLS impersonation: safari15_5, safari17_0, chrome123, chrome124
→ archiveofourown.org
CF Challenge Solving (v4):
1. All proxies hit CF challenge → generate challenge_token
2. Rewrite challenge page → forward to user's browser
3. User browser executes CF JS → challenge solved
4. cf_clearance captured → saved to proxy cookie jar
5. Original request retried → content delivered
```
### Core Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `app.py` | FastAPI backend v4 — proxy handler, stats, CF challenge solving | | `app.py` | FastAPI backend v5 — proxy handler, sticky sessions, SW deploy, stats |
| `proxy_pool.py` | Tiered async proxy pool — cookie jar, weighted selection, sampling | | `proxy_pool.py` | P2C proxy pool v5 — 5000 proxies, state machine, semaphore limiter |
| `ao3_fetcher.py` | Async fetcher — CF detection, smart retry, cookie injection | | `ao3_fetcher.py` | Single-request fetcher v5 — no internal retry, CF detection |
| `cache.py` | Per-worker LRU cache (5000 entries, path-differentiated TTL) | | `cache.py` | Per-worker LRU cache (5000 entries, path-differentiated TTL) |
| `stats.py` | In-memory stats with batch SQLite flush every 60s | | `stats.py` | In-memory stats with batch SQLite flush every 60s |
| `url_rewriter.py` | URL/header rewriting (ao3 → mirror domain) | | `url_rewriter.py` | URL/header rewriting (ao3 → mirror domain) |
| `scripts/daemon.py` | Passive worker supervision (systemd, 30s check, 3-strike restart) | | `static/sw.js` | Service Worker — client-side cache, offline fallback |
| `scripts/scan_proxies_cffi.py` | Proxy scanner with TLS fingerprint fallback | | `Caddyfile` | Caddy config — SW routes, round-robin, 60s timeouts |
| `start.sh` | Startup script — kills old workers, starts new, reloads Caddy | | `scripts/daemon.py` | Passive worker supervision (systemd) |
| `Caddyfile` | Caddy config — TLS, round-robin, 60s timeouts | | `start.sh` | Startup — kills old workers, starts new, reloads Caddy |
## Security Baseline ## Security Baseline
- No secrets in code — proxy credentials are in `/home/ubuntu/proxy.txt` - No secrets in code — proxy credentials in `/home/ubuntu/proxy.txt`
- Worker processes bound to 127.0.0.1 only (not exposed publicly) - Workers bound to 127.0.0.1 only
- Cloudflare CDN terminates TLS and provides DDoS protection at edge - Cloudflare CDN terminates TLS at edge
- CORS headers restrict cross-origin access - CORS headers restrict cross-origin access
- stats/health/metrics endpoints are read-only, no mutation - stats/health/metrics are read-only
- All proxied content is user-facing; no admin endpoints exposed
## Engine Guidance ## Engine Guidance
- Complex multi-file changes, architecture evolution → Hermes (here) - Complex multi-file changes, architecture evolution → Hermes (here)
- Quick targeted fixes, single-file changes → Hermes - Quick targeted fixes, single-file changes → Hermes
- Deploy, monitor, notify, schedule → Hermes - Deploy, monitor, notify, schedule → Hermes
- Proxy scanning, proxy pool refresh → Hermes cron (30min) - Proxy pool refresh → Hermes cron (30min)
- Not sure? Start with Hermes — everything runs via Hermes - Not sure? Start with Hermes — everything runs via Hermes
## Monitoring ## Monitoring
- Health endpoint: https://agento3.miscs.dev/health - Health: https://agento3.miscs.dev/health
- Stats dashboard: https://agento3.miscs.dev/stats - Stats: https://agento3.miscs.dev/stats
- Metrics (Prometheus): https://agento3.miscs.dev/metrics - Metrics: https://agento3.miscs.dev/metrics
- Worker logs: /home/ubuntu/ao3-mirror/worker-0.log, worker-1.log - SW: https://agento3.miscs.dev/sw.js → /sw-YYYYMMDD.js
- Daemon log: /home/ubuntu/ao3-mirror/daemon.log
- Systemd: `systemctl status ao3-daemon`, `journalctl -u ao3-daemon`
## Deployment ## CTO Goal (Persistent Focus)
- Single server (current host) Manage akiba/agento3 as CTO. Triage issues hourly, implement top priority, get founder approval before merging. Never ship without YES.
- Caddy manages Let's Encrypt TLS certs on agento3.miscs.dev
- CF CDN fronts the domain (Orange Cloud = on)
- Proxy pool: WebShare static residential proxies, refreshed every 30min via Hermes cron
- No CI/CD — manual deploy via start.sh
## Commit Conventions ## Commit Conventions
- One commit per meaningful change - One commit per meaningful change
- Python files only (no binaries, no .pyc, no logs, no .env) - Python files only (no binaries, .pyc, logs, .env)
- SOUL.md updated to reflect architectural changes - SOUL.md updated for architectural changes
- ao3-mirror skill updated when workflows change - ao3-mirror skill updated when workflows change

112
Caddyfile
View File

@@ -1,5 +1,5 @@
# AO3 Mirror - Caddy 配置 # AO3 Mirror v5 - Caddy 配置
# 前端负载均衡 + TLS 终止 + Cloudflare CDN 集成 # 前端负载均衡 + TLS 终止 + Cloudflare CDN 集成 + Service Worker
agento3.miscs.dev { agento3.miscs.dev {
# 全局头 # 全局头
@@ -9,6 +9,7 @@ agento3.miscs.dev {
X-Frame-Options "SAMEORIGIN" X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block" X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'"
-Server -Server
-X-Powered-By -X-Powered-By
} }
@@ -26,6 +27,60 @@ agento3.miscs.dev {
# 压缩 # 压缩
encode gzip encode gzip
# Service Worker routes — versioned + redirect
route /sw-* {
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
}
header {
Cache-Control "public, max-age=86400, immutable"
}
}
# SW redirect
route /sw.js {
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
}
header {
Cache-Control "no-store"
}
}
# Mirror domains JSON
route /mirror-domains.json {
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
}
header {
Cache-Control "public, max-age=3600"
}
}
# 后端负载均衡 (轮询) - 2 workers on 2-core machine # 后端负载均衡 (轮询) - 2 workers on 2-core machine
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 { reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin lb_policy round_robin
@@ -34,7 +89,7 @@ agento3.miscs.dev {
health_interval 10s health_interval 10s
health_timeout 5s health_timeout 5s
# 超时配置 # 超时配置 — 代理可能需要更长时间
transport http { transport http {
read_timeout 30s read_timeout 30s
write_timeout 30s write_timeout 30s
@@ -55,6 +110,7 @@ stats.agento3.miscs.dev {
X-Content-Type-Options "nosniff" X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN" X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'"
-Server -Server
-X-Powered-By -X-Powered-By
} }
@@ -72,41 +128,67 @@ stats.agento3.miscs.dev {
# 压缩 # 压缩
encode gzip encode gzip
# 路由规则 - 只暴露 /stats /metrics # 路由规则 - 只暴露 /stats, /metrics, /health, /sw
route { route {
# /stats 页面 - 代理到后端 # /stats 页面
reverse_proxy /stats* 127.0.0.1:8081 127.0.0.1:8082 { reverse_proxy /stats* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin lb_policy round_robin
health_uri /health health_uri /health
health_interval 10s health_interval 10s
health_timeout 5s health_timeout 5s
transport http { transport http {
read_timeout 30s read_timeout 60s
write_timeout 30s write_timeout 60s
dial_timeout 5s dial_timeout 5s
} }
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}
} }
# /metrics 指标 - 代理到后端 # /metrics 指标
reverse_proxy /metrics* 127.0.0.1:8081 127.0.0.1:8082 { reverse_proxy /metrics* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin lb_policy round_robin
health_uri /health health_uri /health
health_interval 10s health_interval 10s
health_timeout 5s health_timeout 5s
transport http { transport http {
read_timeout 30s read_timeout 60s
write_timeout 30s write_timeout 60s
dial_timeout 5s dial_timeout 5s
} }
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
# Service Worker
reverse_proxy /sw* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
# Mirror domains
reverse_proxy /mirror-domains* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}

View File

@@ -1,25 +1,22 @@
""" """
异步 AO3 内容抓取器 v4Cookie 感知 + CF 挑战检测 + 智能重试 异步 AO3 内容抓取器 v5单次代理请求 + CF 挑战检测
v4 vs v3: v5 vs v4:
- CF 挑战检测:不再把 403/503 一律当失败 - 去掉内部重试循环(重试逻辑迁移到 app.py 的 P2C 选择中)
- Cookie 注入:请求时自动携带 proxy 级别的 cookiescf_clearance 等) - 简化 API直接接受 ProxySession 对象
- Cookie 保存:成功请求后自动提取 Set-Cookie 并保存到 proxy cookie jar - 保留 CF 挑战检测 + 状态机反馈
- 智能重试:挑战时优先用带 cookie 的代理,无 cookie 则换代理
- 挑战页面透传:所有重试都遇到挑战时,返回挑战 HTML 让用户浏览器求解
""" """
import asyncio import asyncio
import logging import logging
import time import time
from typing import Optional from typing import Optional
from proxy_pool import get_proxy_pool, ProxySession from proxy_pool import ProxySession
logger = logging.getLogger("ao3-fetcher") logger = logging.getLogger("ao3-fetcher")
# ─── CF Challenge Detection ─────────────────────────────────────────────── # ─── CF Challenge Detection ───────────────────────────────────────────────
# Markers in response body that indicate a Cloudflare challenge page
CF_CHALLENGE_MARKERS = [ CF_CHALLENGE_MARKERS = [
b'/cdn-cgi/challenge-platform', b'/cdn-cgi/challenge-platform',
b'cf-challenge-running', b'cf-challenge-running',
@@ -28,29 +25,24 @@ CF_CHALLENGE_MARKERS = [
b'challenge-platform', b'challenge-platform',
b'cf-turnstile', b'cf-turnstile',
b'cf_chl_', b'cf_chl_',
# Sometimes CF just returns "Just a moment..." without JS markers
b'Checking your browser', b'Checking your browser',
b'Just a moment...', b'Just a moment...',
] ]
def is_cf_challenge(status: int, body: bytes, headers: dict) -> bool: def is_cf_challenge(status: int, body: bytes, headers: dict) -> bool:
"""Detect if response is a Cloudflare challenge page (not a real error).""" """Detect if response is a Cloudflare challenge page."""
if status not in (403, 503, 429): if status not in (403, 503, 429):
return False return False
# Quick check: CF always sets Server header on challenge pages
server = headers.get("Server", headers.get("server", "")) server = headers.get("Server", headers.get("server", ""))
if "cloudflare" not in server.lower(): if "cloudflare" not in server.lower():
# Check body for challenge markers
for marker in CF_CHALLENGE_MARKERS: for marker in CF_CHALLENGE_MARKERS:
if marker in body: if marker in body:
return True return True
return False return False
# Server: cloudflare + non-200 status = likely challenge
for marker in CF_CHALLENGE_MARKERS: for marker in CF_CHALLENGE_MARKERS:
if marker in body: if marker in body:
return True return True
# If Server is cloudflare and status is 403/503, it's almost certainly a challenge
if status in (403, 503): if status in (403, 503):
return True return True
return False return False
@@ -69,31 +61,29 @@ CHROME_HEADERS = {
"Sec-Fetch-Site": "none", "Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1", "Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1", "Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "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", "DNT": "1",
} }
API_HEADERS = { API_HEADERS = {
"Accept": "*/*", "Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9", "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/121.0.0.0 Safari/537.36", "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 ─────────────────────────────────────────────────────── # Timeout config
FAST_REQUEST_TIMEOUT = 8
FAST_REQUEST_TIMEOUT = 8 # login/register/signup
NORMAL_REQUEST_TIMEOUT = 15 NORMAL_REQUEST_TIMEOUT = 15
CONNECT_TIMEOUT = 5
MAX_RETRIES = 2
FAST_MAX_RETRIES = 1
FAST_PATHS = { FAST_PATHS = {
"/users/login", "/users/login", "/users/sign_up", "/users/new",
"/users/sign_up", "/invitation_requests", "/token_dispenser.json", "/user_sessions",
"/users/new",
"/invitation_requests",
"/token_dispenser.json",
"/user_sessions",
} }
@@ -106,200 +96,97 @@ def _is_fast_path(url: str) -> bool:
return False return False
def _now() -> float:
try:
return asyncio.get_running_loop().time()
except RuntimeError:
return time.time()
def _merge_cookies(proxy_cookies: str, user_cookies: str) -> str: def _merge_cookies(proxy_cookies: str, user_cookies: str) -> str:
"""Merge proxy-level cookies (cf_clearance) with user cookies. User cookies take precedence."""
if not proxy_cookies and not user_cookies: if not proxy_cookies and not user_cookies:
return "" return ""
if not proxy_cookies: if not proxy_cookies:
return user_cookies return user_cookies
if not user_cookies: if not user_cookies:
return proxy_cookies return proxy_cookies
# User cookies take priority (they contain session auth)
# But put proxy cookies first so user cookies can override
return f"{proxy_cookies}; {user_cookies}" return f"{proxy_cookies}; {user_cookies}"
# ─── Main fetch function ──────────────────────────────────────────────────
async def fetch_url( async def fetch_url(
url: str, url: str,
proxy: Optional[ProxySession] = None,
method: str = "GET", method: str = "GET",
headers: Optional[dict] = None, headers: Optional[dict] = None,
body: Optional[bytes] = None, body: Optional[bytes] = None,
cookies: Optional[dict] = None, cookies: Optional[dict] = None,
is_api: bool = False, is_api: bool = False,
preferred_proxy: Optional[str] = None, # v4: proxy host:port for challenge affinity
) -> dict: ) -> dict:
""" """
Async fetch from AO3 through proxy pool with cookie-aware session reuse. Single async fetch through a specific proxy. No internal retry loop.
Priority-based: Args:
- Login/register paths → fast pool + 8s timeout + 1 retry url: Target AO3 URL
- Normal paths → cookie-preferring proxies + 15s timeout + 2 retries 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
v4 improvements: Returns:
- CF challenge detection: don't treat challenge as proxy failure dict with success/status/headers/body/cookies/elapsed/is_challenge etc.
- Cookie injection: auto-send saved cf_clearance per proxy
- Cookie saving: auto-extract Set-Cookie on success
- Challenge body returned for user-browser solving
Returns: {
"status": 200,
"headers": {...},
"body": b"...",
"cookies": {...},
"success": True/False,
"error": "...",
"elapsed": 0.5,
"proxy_host": "p.webshare.io:10296",
# v4:
"is_challenge": False,
"challenge_body": b"" | None, # CF challenge HTML for user-browser solving
"challenge_proxy": "" | None, # which proxy got the challenge
}
""" """
pool = get_proxy_pool() if proxy is None:
base_headers = API_HEADERS.copy() if is_api else CHROME_HEADERS.copy() 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: if headers:
for h in ["Cookie", "Referer", "Content-Type", "X-Requested-With", "Accept", for h in ["Cookie", "Content-Type", "X-Requested-With",
"Origin", "X-CSRF-Token", "Authorization"]: "Accept", "X-CSRF-Token", "Authorization"]:
if h in headers: if h in headers:
base_headers[h] = headers[h] 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 = "" user_cookie_str = ""
if cookies: if cookies:
user_cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items()) user_cookie_str = "; ".join(
f"{k}={v}" for k, v in cookies.items()
# Determine priority level 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
max_retries = FAST_MAX_RETRIES if is_fast else MAX_RETRIES
last_error = None
last_challenge_body = None
last_challenge_proxy = None
seen_proxies = set() # Don't retry with same proxy
# v4: if preferred_proxy is set (from challenge affinity), use it first
if preferred_proxy:
try:
target_proxy = None
for p in pool._proxies:
if p.host_port == preferred_proxy:
target_proxy = p
break
if target_proxy and target_proxy.is_available:
result = await _try_proxy(
target_proxy, url, method, body, base_headers, user_cookie_str,
request_timeout, is_fast
)
if result["success"]:
# preferred proxy worked — save cookies
target_proxy.save_cookies(result.get("headers", {}))
return result
if result.get("is_challenge"):
last_challenge_body = result.get("challenge_body")
last_challenge_proxy = target_proxy.host_port
seen_proxies.add(target_proxy.host_port)
except Exception:
pass
for attempt in range(max_retries):
# v4: prefer proxies with cookies, fallback to any available
if attempt == 0:
# First attempt: use proxy with cookies if available
proxy = pool.get_proxy_with_cookies()
if not proxy:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
elif attempt == 1 and last_challenge_body:
# Second attempt after challenge: try another proxy with cookies
proxy = pool.get_proxy_with_cookies()
# Make sure it's not the same one
if proxy and proxy.host_port in seen_proxies:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
if not proxy:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
else:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
if not proxy or proxy.host_port in seen_proxies:
# Find an unseen proxy
for _ in range(5):
p = pool.get_proxy()
if p and p.host_port not in seen_proxies:
proxy = p
break
if not proxy or proxy.host_port in seen_proxies:
continue
seen_proxies.add(proxy.host_port)
result = await _try_proxy(
proxy, url, method, body, base_headers, user_cookie_str,
request_timeout, is_fast
) )
if result["success"]: is_fast = _is_fast_path(url) or method in ("POST", "PUT", "PATCH")
# Save response cookies to proxy cookie jar for future requests request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
proxy.save_cookies(result.get("headers", {}))
return result
if result.get("is_challenge"):
last_challenge_body = result.get("challenge_body")
last_challenge_proxy = proxy.host_port
last_error = result.get("error", "CF_CHALLENGE")
# Don't break — try another proxy
continue
# Real failure (connection error, timeout)
last_error = result.get("error", "UNKNOWN")
# All retries exhausted
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"success": False,
"error": f"All retries failed: {last_error}",
"elapsed": 0, "proxy_host": None,
"is_challenge": last_challenge_body is not None,
"challenge_body": last_challenge_body,
"challenge_proxy": last_challenge_proxy,
}
async def _try_proxy(
proxy: ProxySession,
url: str,
method: str,
body: Optional[bytes],
base_headers: dict,
user_cookie_str: str,
request_timeout: int,
is_fast: bool,
) -> dict:
"""Try a single request through one proxy. Returns result dict."""
host_port = f"{proxy.host}:{proxy.port}"
start_time = _now()
try: try:
session = await proxy.get_session() session = await proxy.get_session()
# v4: Build per-request headers with proxy cookies
# IMPORTANT: use headers= parameter (never session.headers.update() — race condition!)
request_headers = base_headers.copy() request_headers = base_headers.copy()
# Inject proxy-level cookies (cf_clearance, etc.) # Inject proxy-level cookies (cf_clearance)
proxy_cookie_str = proxy.get_cookie_header() proxy_cookie_str = proxy.get_cookie_header()
cookie_str = _merge_cookies(proxy_cookie_str, user_cookie_str) cookie_str = _merge_cookies(proxy_cookie_str, user_cookie_str)
if cookie_str: if cookie_str:
request_headers["Cookie"] = cookie_str request_headers["Cookie"] = cookie_str
# Execute request # 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": if method == "GET":
resp = await session.get(url, timeout=request_timeout, headers=request_headers) resp = await session.get(url, timeout=request_timeout, headers=request_headers)
elif method == "POST": elif method == "POST":
@@ -310,89 +197,79 @@ async def _try_proxy(
resp = await session.request(method, url, data=body, timeout=request_timeout, resp = await session.request(method, url, data=body, timeout=request_timeout,
headers=request_headers) headers=request_headers)
elapsed = _now() - start_time elapsed = time.time() - start_time
status = resp.status_code status = resp.status_code
resp_body = resp.content resp_body = resp.content
resp_headers = dict(resp.headers) 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 = {} resp_cookies = {}
if hasattr(resp, "cookies"): if hasattr(resp, "cookies"):
for k, v in resp.cookies.items(): for k, v in resp.cookies.items():
resp_cookies[k] = v resp_cookies[k] = v
# v4: Check for CF challenge # CF challenge detection
if is_cf_challenge(status, resp_body, resp_headers): if is_cf_challenge(status, resp_body, resp_headers):
proxy.mark_challenged() logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status}")
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status} ({elapsed:.1f}s)")
return { return {
"status": status, "status": status, "headers": resp_headers, "body": resp_body,
"headers": resp_headers, "headers_raw": resp_headers_raw,
"body": resp_body,
"cookies": resp_cookies, "cookies": resp_cookies,
"success": False, "success": False, "error": f"CF_CHALLENGE_{status}",
"error": f"CF_CHALLENGE_{status}", "elapsed": elapsed, "proxy_host": host_port,
"elapsed": elapsed, "is_challenge": True, "challenge_body": resp_body,
"proxy_host": host_port,
"is_challenge": True,
"challenge_body": resp_body,
"challenge_proxy": host_port, "challenge_proxy": host_port,
} }
# Success — any 2xx-4xx is proxied through # Success
if 200 <= status < 500: if 200 <= status < 500:
proxy.mark_success(elapsed) sc_from_dict = resp_headers.get("Set-Cookie", resp_headers.get("set-cookie", "NONE"))
logger.debug(f"{host_port} -> {url[:60]}: {status} ({elapsed:.2f}s)") logger.info(f"[SAVE_COOKIES] proxy={host_port} set_cookie_dict={sc_from_dict[:120]}...")
# v4: Save response cookies to proxy
proxy.save_cookies(resp_headers) proxy.save_cookies(resp_headers)
return { return {
"status": status, "status": status, "headers": resp_headers, "body": resp_body,
"headers": resp_headers, "headers_raw": resp_headers_raw,
"body": resp_body,
"cookies": resp_cookies, "cookies": resp_cookies,
"success": True, "success": True,
"elapsed": elapsed, "elapsed": elapsed, "proxy_host": host_port,
"proxy_host": host_port, "is_challenge": False, "challenge_body": None, "challenge_proxy": None,
"is_challenge": False,
"challenge_body": None,
"challenge_proxy": None,
} }
# True error status (5xx) # Error status
proxy.mark_failure() logger.warning(f"{host_port} -> {url[:60]}: {status}")
logger.warning(f"Attempt: {host_port} -> {url[:60]}: {status}")
return { return {
"status": status, "status": status, "headers": resp_headers, "body": resp_body,
"headers": resp_headers, "headers_raw": resp_headers_raw,
"body": resp_body,
"cookies": resp_cookies, "cookies": resp_cookies,
"success": False, "success": False, "error": f"HTTP_{status}",
"error": f"HTTP_{status}", "elapsed": elapsed, "proxy_host": host_port,
"elapsed": elapsed, "is_challenge": False, "challenge_body": None, "challenge_proxy": None,
"proxy_host": host_port,
"is_challenge": False,
"challenge_body": None,
"challenge_proxy": None,
} }
except asyncio.TimeoutError: except asyncio.TimeoutError:
proxy.mark_failure() elapsed = time.time() - start_time
elapsed = _now() - start_time logger.warning(f"TIMEOUT: {host_port} -> {url[:60]}")
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]} ({elapsed:.1f}s, timeout={request_timeout}s)")
return { return {
"status": 0, "headers": {}, "body": b"", "cookies": {}, "status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": "TIMEOUT", "success": False, "error": "TIMEOUT",
"elapsed": elapsed, "proxy_host": host_port, "elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None, "is_challenge": False, "challenge_body": None, "challenge_proxy": None,
} }
except Exception as e: except Exception as e:
proxy.mark_failure() elapsed = time.time() - start_time
elapsed = _now() - start_time
err_str = str(e)[:120] err_str = str(e)[:120]
logger.debug(f"ERROR: {host_port} -> {e} ({elapsed:.1f}s)") logger.debug(f"ERROR: {host_port} -> {err_str}")
return { return {
"status": 0, "headers": {}, "body": b"", "cookies": {}, "status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": err_str, "success": False, "error": err_str,
"elapsed": elapsed, "proxy_host": host_port, "elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None, "is_challenge": False, "challenge_body": None, "challenge_proxy": None,

590
app.py
View File

@@ -1,13 +1,17 @@
""" """
AO3 反代后端 v4Cookie 感知 + CF 挑战用户浏览器求解 AO3 反代后端 v5粘性会话 + Service Worker + P2C 代理池 + 并发限流
v4 vs v3: v5 vs v4:
- Challenge token 映射:用户浏览器求解 CF 挑战时,保证同一 proxy 亲和性 - 粘性会话: ao3_sessid_proxy cookie (对标 go3)
- 挑战页面透传:所有代理都遇到 CF 挑战时,把挑战页发给用户浏览器求解 - Service Worker: /sw.js, /sw-YYYYMMDD.js for client-side caching
- Cookie 流:成功响应自动保存 proxy cookie后续请求自动携带 - HTML 注入: 自动注入 SW 注册脚本 + 镜像域名
- 统计面板展示 cookie-aware 代理数 - P2C 代理选择: Power of Two Choices (对标 go3)
- 并发限流: asyncio.Semaphore(400) (Webshare 上限)
- 全量 5000 代理: 不再按端口过滤
- 状态机: healthy/unstable/blocked/probing
""" """
import hashlib import hashlib
import html
import json import json
import logging import logging
import os import os
@@ -23,9 +27,9 @@ import uvicorn
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from proxy_pool import get_proxy_pool from proxy_pool import get_proxy_pool, MAX_CONCURRENT_REQUESTS, OUTAGE_THRESHOLD
from ao3_fetcher import fetch_url, is_cf_challenge from ao3_fetcher import fetch_url, is_cf_challenge, FAST_REQUEST_TIMEOUT, NORMAL_REQUEST_TIMEOUT
from url_rewriter import rewrite_body, rewrite_response_headers, needs_rewrite, MIRROR_DOMAIN from url_rewriter import rewrite_body, rewrite_response_headers, rewrite_response_headers_raw, needs_rewrite, MIRROR_DOMAIN
from cache import get_cache, get_ttl_for_path from cache import get_cache, get_ttl_for_path
from stats import get_stats_collector from stats import get_stats_collector
@@ -39,13 +43,15 @@ logger = logging.getLogger("ao3-backend")
AO3_BASE = "https://archiveofourown.org" AO3_BASE = "https://archiveofourown.org"
MIRROR_HOST = MIRROR_DOMAIN MIRROR_HOST = MIRROR_DOMAIN
# Service Worker version date (change on SW updates)
SERVICE_WORKER_DATE = "20260623"
LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"} LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"}
# ─── Challenge Token Map (v4) ────────────────────────────────────────────── # ─── Challenge Token Map (v4 retained) ──────────────────────────────────────
# Maps challenge_token → (method, ao3_url, headers, body, cookies, proxy_host, expires)
_challenge_map: dict[str, tuple] = {} _challenge_map: dict[str, tuple] = {}
_challenge_lock = threading.Lock() _challenge_lock = threading.Lock()
CHALLENGE_TOKEN_TTL = 120 # 2 minutes for the user's browser to solve the challenge CHALLENGE_TOKEN_TTL = 120
def _make_challenge_token() -> str: def _make_challenge_token() -> str:
@@ -55,7 +61,6 @@ def _make_challenge_token() -> str:
def _store_challenge(token: str, method: str, ao3_url: str, headers: dict, def _store_challenge(token: str, method: str, ao3_url: str, headers: dict,
body: bytes, cookies: dict, proxy_host: str): body: bytes, cookies: dict, proxy_host: str):
with _challenge_lock: with _challenge_lock:
# Clean expired tokens
now = time.time() now = time.time()
expired = [k for k, v in _challenge_map.items() if v[6] < now] expired = [k for k, v in _challenge_map.items() if v[6] < now]
for k in expired: for k in expired:
@@ -71,16 +76,38 @@ def _get_challenge(token: str) -> tuple | None:
del _challenge_map[token] del _challenge_map[token]
return entry return entry
if entry: if entry:
del _challenge_map[token] # expired del _challenge_map[token]
return None return None
# ─── 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
# ─── App ─────────────────────────────────────────────────────────────────── # ─── App ───────────────────────────────────────────────────────────────────
app = FastAPI( app = FastAPI(
title="AO3 Mirror", title="AO3 Mirror",
description="AO3 reverse proxy mirror for Chinese users", description="AO3 reverse proxy mirror for Chinese users",
version="4.0.0", version="5.0.0",
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
) )
@@ -106,31 +133,51 @@ def build_ao3_url(path: str, query: str = "") -> str:
# ─── Challenge page URL rewriting ────────────────────────────────────────── # ─── Challenge page URL rewriting ──────────────────────────────────────────
def _rewrite_challenge_page(body: bytes, proxy_host: str, token: str) -> bytes: 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.""" """Rewrite CF challenge page — inject retry UI + auto cookie."""
import re # Rewrite domain
# Basic AO3 domain rewrite
body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode()) body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode())
# 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")
# Rewrite /cdn-cgi/ challenge endpoints to go through mirror # Inject cookie + retry overlay
# These are CF's internal challenge platform URLs retry_overlay = (
body = body.replace( f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'.encode()
b"/cdn-cgi/challenge-platform", + 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}'
f"/cdn-cgi/challenge-platform".encode() + 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>'
) )
body = body.replace(b"<body", retry_overlay + b"<body", 1)
# Inject a marker meta tag so we can detect challenge pages coming back if b"<body" not in body:
marker = f'<meta name="cf-challenge-proxy" content="{proxy_host}">'.encode() body = retry_overlay + body
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 return body
# ─── 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
# ─── Response header helpers ─────────────────────────────────────────────── # ─── Response header helpers ───────────────────────────────────────────────
def filter_response_headers(headers: dict) -> dict: def filter_response_headers(headers: dict) -> dict:
@@ -149,7 +196,7 @@ def filter_response_headers(headers: dict) -> dict:
if key.lower().startswith("cf-"): if key.lower().startswith("cf-"):
continue continue
result[key] = value result[key] = value
result["X-Proxy"] = "AO3-Mirror/4.0" result["X-Proxy"] = "AO3-Mirror/5.0"
result["X-Cache"] = "MISS" result["X-Cache"] = "MISS"
result["Cache-Control"] = "public, max-age=60, s-maxage=60" result["Cache-Control"] = "public, max-age=60, s-maxage=60"
return result return result
@@ -169,15 +216,12 @@ def add_cors(response: Response):
async def health(): async def health():
pool = get_proxy_pool() pool = get_proxy_pool()
stats = pool.get_stats() stats = pool.get_stats()
resp = JSONResponse({ return JSONResponse({
"status": "ok", "status": "ok",
"timestamp": time.time(), "timestamp": time.time(),
"version": "4.0.0", "version": "5.0.0",
"proxy_pool": stats, "proxy_pool": stats,
}) })
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
resp.headers["Pragma"] = "no-cache"
return resp
@app.get("/robots.txt") @app.get("/robots.txt")
@@ -187,6 +231,43 @@ async def robots():
) )
# ─── 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,
})
# ─── Stats Dashboard ─────────────────────────────────────────────────────── # ─── Stats Dashboard ───────────────────────────────────────────────────────
@app.get("/stats", response_class=HTMLResponse) @app.get("/stats", response_class=HTMLResponse)
@@ -213,7 +294,7 @@ async def stats_page():
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AO3 Mirror - 统计面板 v4</title> <title>AO3 Mirror - 统计面板 v5</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style> <style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }} * {{ margin: 0; padding: 0; box-sizing: border-box; }}
@@ -241,12 +322,13 @@ async def stats_page():
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; 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-green {{ background: rgba(74, 222, 128, 0.15); color: #4ade80; }}
.badge-red {{ background: rgba(248, 113, 113, 0.15); color: #f87171; }} .badge-red {{ background: rgba(248, 113, 113, 0.15); color: #f87171; }}
.badge-yellow {{ background: rgba(251, 191, 36, 0.15); color: #fbbf24; }}
@media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }} @media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>🛡 AO3 Mirror 统计面板 v4</h1> <h1>🛡 AO3 Mirror 统计面板 v5</h1>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
@@ -275,14 +357,14 @@ async def stats_page():
<div class="sub">P99: {stats['p99_elapsed_ms']} ms</div> <div class="sub">P99: {stats['p99_elapsed_ms']} ms</div>
</div> </div>
<div class="card"> <div class="card">
<div class="label">代理池</div> <div class="label">代理池 ({proxy_stats['total']})</div>
<div class="value green">{proxy_stats['alive']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></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['available']} | 受损: {proxy_stats['dead']} | Banned: {proxy_stats['banned']}</div> <div class="sub">不稳定: {proxy_stats['unstable']} | 封禁: {proxy_stats['blocked']} | 探测: {proxy_stats['probing']}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="label">Cookie 代理</div> <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="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">已持有 cf_clearance</div> <div class="sub">inflight: {proxy_stats['total_inflight']}</div>
</div> </div>
<div class="card"> <div class="card">
<div class="label">本地缓存</div> <div class="label">本地缓存</div>
@@ -306,7 +388,7 @@ async def stats_page():
<h2>热门路径</h2> <h2>热门路径</h2>
<table> <table>
<tr><th>路径</th><th>请求数</th><th>成功</th><th>平均延迟</th></tr> <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])} {''.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])}
</table> </table>
</div> </div>
</div> </div>
@@ -394,11 +476,13 @@ async def metrics():
f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}', f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}',
"# HELP ao3_mirror_proxy_pool Proxy pool status", "# HELP ao3_mirror_proxy_pool Proxy pool status",
"# TYPE ao3_mirror_proxy_pool gauge", "# TYPE ao3_mirror_proxy_pool gauge",
f'ao3_mirror_proxy_alive {proxy_stats["alive"]}', f'ao3_mirror_proxy_total {proxy_stats["total"]}',
f'ao3_mirror_proxy_dead {proxy_stats["dead"]}', f'ao3_mirror_proxy_healthy {proxy_stats["healthy"]}',
f'ao3_mirror_proxy_banned {proxy_stats["banned"]}', f'ao3_mirror_proxy_unstable {proxy_stats["unstable"]}',
f'ao3_mirror_proxy_available {proxy_stats["available"]}', f'ao3_mirror_proxy_blocked {proxy_stats["blocked"]}',
f'ao3_mirror_proxy_probing {proxy_stats["probing"]}',
f'ao3_mirror_proxy_with_cookies {proxy_stats["with_cookies"]}', f'ao3_mirror_proxy_with_cookies {proxy_stats["with_cookies"]}',
f'ao3_mirror_proxy_inflight {proxy_stats["total_inflight"]}',
"# HELP ao3_mirror_cache_size Current cache size", "# HELP ao3_mirror_cache_size Current cache size",
"# TYPE ao3_mirror_cache_size gauge", "# TYPE ao3_mirror_cache_size gauge",
f'ao3_mirror_cache_size {cache_stats["size"]}', f'ao3_mirror_cache_size {cache_stats["size"]}',
@@ -406,11 +490,11 @@ async def metrics():
return PlainTextResponse("\n".join(lines)) return PlainTextResponse("\n".join(lines))
# ─── Proxy Core (v4) ────────────────────────────────────────────────────── # ─── Proxy handler (v5) ──────────────────────────────────────────────────
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"]) @app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
async def proxy_handler(request: Request, path: str): async def proxy_handler(request: Request, path: str):
"""Main proxy handler — with cookie-aware fetching and CF challenge solving.""" """Main proxy handler — sticky sessions + P2C selection + semaphore-limited."""
start_time = time.time() start_time = time.time()
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
@@ -418,88 +502,93 @@ async def proxy_handler(request: Request, path: str):
path = "" path = ""
full_path = f"/{path}" if path else "/" full_path = f"/{path}" if path else "/"
if full_path in LOCAL_PATHS or full_path.startswith("/stats") or full_path.startswith("/health"): if full_path in LOCAL_PATHS or full_path.startswith("/sw"):
return JSONResponse({"error": "Not found"}, status_code=404) return JSONResponse({"error": "Not found"}, status_code=404)
# Remap /__cf/ → /cdn-cgi/ (CF challenge paths bypass our CDN)
if full_path.startswith("/__cf/"):
path = "cdn-cgi" + path[4:]
full_path = "/" + path
if request.method == "OPTIONS": if request.method == "OPTIONS":
resp = Response() resp = Response()
add_cors(resp) add_cors(resp)
return resp return resp
# ── v4: Challenge token check ────────────────────────────────────────── pool = get_proxy_pool()
# If this request has a _cf_token cookie, it's part of a challenge resolution flow query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# ── v4: Challenge token check ────────────────────────────────────────
cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token") cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token")
preferred_proxy = None
if cf_token: if cf_token:
challenge_entry = _get_challenge(cf_token) challenge_entry = _get_challenge(cf_token)
if challenge_entry: if challenge_entry:
orig_method, orig_url, orig_headers, orig_body, orig_cookies, proxy_host, _ = 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}")
logger.info(f"Challenge resolution: using proxy {proxy_host} for {orig_url[:60]}") challenge_proxy_obj = pool.get_by_addr(proxy_host)
# Re-fetch the original request with the challenge proxy
result = await fetch_url( result = await fetch_url(
url=orig_url, method=orig_method, url=orig_url, method=orig_method,
proxy=challenge_proxy_obj,
headers=dict(request.headers), headers=dict(request.headers),
body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None, body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None,
cookies=orig_cookies, cookies=orig_cookies,
is_api="/api/" in orig_url, is_api="/api/" in orig_url,
preferred_proxy=preferred_proxy,
) )
if result["success"]: if result["success"]:
# Challenge solved! Proxy now has cf_clearance. Return content.
elapsed = time.time() - start_time elapsed = time.time() - start_time
rewritten_body = rewrite_body(result["body"], rewritten_body = rewrite_body(result["body"],
result["headers"].get("Content-Type", "")) 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)
rewritten_headers = rewrite_response_headers(result["headers"]) rewritten_headers = rewrite_response_headers(result["headers"])
final_headers = filter_response_headers(rewritten_headers) final_headers = filter_response_headers(rewritten_headers)
final_headers.pop("Set-Cookie", None)
final_headers.pop("set-cookie", None)
final_headers["X-Cache"] = "MISS" final_headers["X-Cache"] = "MISS"
final_headers["X-CF-Status"] = "solved" 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"], resp = Response(content=rewritten_body, status_code=result["status"],
headers=final_headers) 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 # 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)
resp.delete_cookie("_cf_token", path="/") resp.delete_cookie("_cf_token", path="/")
collector = get_stats_collector() collector = get_stats_collector()
collector.log_request(method=orig_method, path=full_path, collector.log_request(method=orig_method, path=full_path,
status=result["status"], elapsed=elapsed, status=result["status"], elapsed=elapsed,
cached=False, proxy_host=preferred_proxy, cached=False, proxy_host=proxy_host,
client_ip=client_ip) client_ip=client_ip)
add_cors(resp) add_cors(resp)
return resp return resp
# ── Normal request flow ──────────────────────────────────────────────── # ── Static cache check (static assets only) ────────────────────
from cache import is_static_path
query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# Check cache
cache = get_cache() cache = get_cache()
cached = cache.get(ao3_url, dict(request.headers)) if request.method == "GET" and is_static_path(full_path):
if cached: cached = cache.get(ao3_url)
body, resp_headers, status = cached if cached:
elapsed = time.time() - start_time body, resp_headers, status = cached
collector = get_stats_collector() elapsed = time.time() - start_time
collector.log_request(method=request.method, path=full_path, status=status, collector = get_stats_collector()
elapsed=elapsed, cached=True, client_ip=client_ip) collector.log_request(method=request.method, path=full_path, status=status,
headers = filter_response_headers(resp_headers) elapsed=elapsed, cached=True, client_ip=client_ip)
headers["X-Cache"] = "HIT" return Response(content=body, status_code=status, headers=resp_headers)
return Response(content=body, status_code=status, headers=headers)
# Prepare request # ── Prepare request ──────────────────────────────────────────────────
method = request.method method = request.method
client_headers = dict(request.headers) client_headers = dict(request.headers)
req_body = None req_body = None
@@ -514,101 +603,167 @@ async def proxy_handler(request: Request, path: str):
k, v = pair.split("=", 1) k, v = pair.split("=", 1)
cookies[k.strip()] = v.strip() cookies[k.strip()] = v.strip()
# Forward to AO3 # ── v5: Sticky proxy + P2C selection with semaphore ──────────────────
result = await fetch_url( sticky_idx, had_sticky = get_sticky_proxy_idx(request)
url=ao3_url, method=method, final_proxy_idx = sticky_idx
headers=client_headers, body=req_body, cookies=cookies, tried_idxs: set[int] = set()
is_api="/api/" in full_path or full_path.startswith("/api/"),
preferred_proxy=preferred_proxy, 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
elapsed = time.time() - start_time elapsed = time.time() - start_time
# ── v4: CF Challenge handling ────────────────────────────────────────── # ── All proxies exhausted — outage ───────────────────────────────────
if result.get("is_challenge") and result.get("challenge_body"): if not result or not result["success"]:
challenge_proxy = result.get("challenge_proxy", "unknown") if result and result.get("is_challenge") and result.get("challenge_body"):
challenge_body = result["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
# Generate challenge token for proxy affinity # Check if we hit outage threshold
token = _make_challenge_token() if pool.pool_unavailable_ratio() >= OUTAGE_THRESHOLD:
_store_challenge(token, method, ao3_url, client_headers, logger.error(f"OUTAGE: {pool.pool_unavailable_ratio():.1%} proxies unavailable")
req_body or b"", cookies, challenge_proxy) return HTMLResponse(
content=_outage_page(), status_code=503
)
# Rewrite challenge page for user's browser logger.error(f"Failed: {ao3_url[:80]}: all retries exhausted")
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 = get_stats_collector()
collector.log_request(method=method, path=full_path, status=502, collector.log_request(method=method, path=full_path, status=502,
elapsed=elapsed, cached=False, client_ip=client_ip) elapsed=elapsed, cached=False, client_ip=client_ip)
return HTMLResponse(content=_transient_error_page(), status_code=502)
error_html = f"""<!DOCTYPE html> # ── Success ──────────────────────────────────────────────────────────
<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_status = result["status"]
ao3_headers = result.get("headers", {}) ao3_headers = result.get("headers", {})
ao3_headers_raw = result.get("headers_raw", [])
raw_body = result.get("body", b"") raw_body = result.get("body", b"")
content_type = ao3_headers.get("Content-Type", "") content_type = ao3_headers.get("Content-Type", ao3_headers.get("content-type", ""))
rewritten_body = rewrite_body(raw_body, content_type) rewritten_body = rewrite_body(raw_body, content_type)
# Inject Service Worker into HTML responses
if "text/html" in content_type:
rewritten_body = inject_service_worker(rewritten_body)
rewritten_headers = rewrite_response_headers(ao3_headers) rewritten_headers = rewrite_response_headers(ao3_headers)
final_headers = filter_response_headers(rewritten_headers) final_headers = filter_response_headers(rewritten_headers)
# Remove Set-Cookie from dict — multi-value Set-Cookie from raw below
final_headers.pop("Set-Cookie", None)
final_headers.pop("set-cookie", None)
final_headers["X-Cache"] = "MISS" final_headers["X-Cache"] = "MISS"
# Cache successful GET responses # Cache successful static GET responses (disk MD5 cache, like go3)
if method == "GET" and 200 <= ao3_status < 400: if method == "GET" and ao3_status == 200 and is_static_path(full_path):
ttl = get_ttl_for_path(full_path) cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status)
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status, ttl=ttl)
# Handle redirects # Handle redirects
if 300 <= ao3_status < 400 and "location" in rewritten_headers: if 300 <= ao3_status < 400 and "location" in rewritten_headers:
@@ -624,27 +779,94 @@ h1 {{ color: #990000; }}
client_ip=client_ip) client_ip=client_ip)
resp = Response(content=rewritten_body, status_code=ao3_status, headers=final_headers) 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(): # Append ALL Set-Cookie headers from raw (preserves multi-value — pitfall #18a)
if k.lower() == "set-cookie": # Filter: strip AO3 CF cookies — our own CF edge handles these
v_rewritten = v.replace("domain=archiveofourown.org", SKIP_COOKIES = {"__cf_bm", "_cfuvid"}
f"domain={MIRROR_DOMAIN}") if ao3_headers_raw:
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org", rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
f"domain=.{MIRROR_DOMAIN}") for key, value in rewritten_raw:
resp.headers.add("Set-Cookie", v_rewritten) 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",
)
add_cors(resp) add_cors(resp)
return resp return resp
# ─── 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)
# ─── Startup / Shutdown ──────────────────────────────────────────────────── # ─── Startup / Shutdown ────────────────────────────────────────────────────
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
logger.info("AO3 Mirror backend v4 starting up...") logger.info("AO3 Mirror backend v5 starting up...")
get_proxy_pool() pool = get_proxy_pool()
get_cache() get_cache()
get_stats_collector() get_stats_collector()
logger.info("AO3 Mirror backend v4 started (cookie-aware + CF challenge solving)") logger.info(f"AO3 Mirror backend v5 started ({len(pool.proxies)} proxies, "
f"P2C selection, sticky sessions, SW deploy)")
@app.on_event("shutdown") @app.on_event("shutdown")
@@ -660,7 +882,7 @@ async def shutdown():
if __name__ == "__main__": if __name__ == "__main__":
port = int(os.environ.get("PORT", "8080")) port = int(os.environ.get("PORT", "8080"))
workers = int(os.environ.get("WORKERS", "4")) workers = int(os.environ.get("WORKERS", "4"))
logger.info(f"Starting AO3 Mirror v4 on port {port} with {workers} workers") logger.info(f"Starting AO3 Mirror v5 on port {port} with {workers} workers")
uvicorn.run( uvicorn.run(
"app:app", "app:app",

225
cache.py
View File

@@ -1,123 +1,164 @@
""" """
简单高效的缓存层 静态资源磁盘缓存 v5 — 对标 go3
- 内存 LRU 缓存,每个 worker 独立
- 短 TTL 避免内容过时
- 针对不同路径设置不同 TTL
"""
只缓存明确是静态资源的路径CSS/JS/图片/字体)。
动态 HTML / API / 登录流程 零缓存 — 直接透传。
缓存文件以 MD5(URL) 命名,存储在 cache/ 目录。
go3 参考: staticExtensions map + MD5 cache
"""
import hashlib import hashlib
import logging
import os
import threading import threading
import time import time
from collections import OrderedDict
from typing import Optional from typing import Optional
logger = logging.getLogger("ao3-cache")
class LRUCache: CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
"""Thread-safe LRU cache with TTL support."""
def __init__(self, capacity: int = 2000, default_ttl: int = 30): # Only cache these extensions — everything else passes through
self.capacity = capacity STATIC_EXTENSIONS = {
self.default_ttl = default_ttl ".css", ".js", ".jpg", ".jpeg", ".png", ".gif", ".ico",
self._cache: OrderedDict[str, tuple[float, bytes, dict, int]] = OrderedDict() ".woff", ".woff2", ".ttf", ".svg", ".webp",
# (expiry_time, body, headers, status) }
self._lock = threading.RLock()
def _make_key(self, url: str, headers: Optional[dict] = None) -> str: # Path prefixes that are always static
"""Generate cache key from URL and key headers.""" STATIC_PATH_PREFIXES = (
# Use URL + relevant headers "/stylesheets/", "/javascripts/", "/images/", "/media/",
accept = "" "/skins/", "/assets/", "/favicon.ico",
if headers: )
accept = headers.get("Accept-Encoding", "")
raw = f"{url}|{accept}"
return hashlib.md5(raw.encode()).hexdigest()
def get(self, url: str, headers: Optional[dict] = None) -> Optional[tuple[bytes, dict, int]]: # Cache TTL: 7 days for static assets (like go3's static_cache_ttl_seconds)
"""Get cached response. Returns (body, headers, status) or None.""" STATIC_TTL = 604800 # 7 days
key = self._make_key(url, headers)
with self._lock:
if key not in self._cache:
return None
expiry, body, resp_headers, status = self._cache[key]
if time.time() > expiry:
del self._cache[key]
return None
# Move to end (most recently used)
self._cache.move_to_end(key)
return (body, resp_headers, status)
def set(self, url: str, body: bytes, headers: dict, status: int,
ttl: Optional[int] = None, request_headers: Optional[dict] = None):
"""Store response in cache."""
key = self._make_key(url, request_headers)
t = ttl if ttl is not None else self.default_ttl
expiry = time.time() + t
with self._lock: def is_static_path(path: str) -> bool:
self._cache[key] = (expiry, body, headers, status) """Check if a URL path points to a static asset."""
self._cache.move_to_end(key) path_lower = path.lower()
if len(self._cache) > self.capacity: for ext in STATIC_EXTENSIONS:
self._cache.popitem(last=False) if path_lower.endswith(ext):
return True
for prefix in STATIC_PATH_PREFIXES:
if path_lower.startswith(prefix):
return True
return False
def invalidate(self, url: str, headers: Optional[dict] = None):
"""Remove a specific URL from cache."""
key = self._make_key(url, headers)
with self._lock:
self._cache.pop(key, None)
def clear(self): class StaticCache:
with self._lock: """Disk-based static file cache. No LRU, no TTL on dynamic content."""
self._cache.clear()
@property def __init__(self):
def size(self) -> int: os.makedirs(CACHE_DIR, exist_ok=True)
with self._lock: self._lock = threading.Lock()
return len(self._cache) self._hits = 0
self._misses = 0
def _cache_path(self, url: str) -> str:
h = hashlib.md5(url.encode()).hexdigest()
return os.path.join(CACHE_DIR, h)
def get(self, url: str) -> Optional[tuple[bytes, dict, int]]:
"""Get cached static file. Returns (body, headers, status) or None."""
if not is_static_path(url):
return None
path = self._cache_path(url)
try:
with self._lock:
if os.path.exists(path):
mtime = os.path.getmtime(path)
if time.time() - mtime < STATIC_TTL:
with open(path, "rb") as f:
body = f.read()
self._hits += 1
# Minimal headers for static content
headers = {
"Content-Type": _guess_content_type(url),
"Cache-Control": f"public, max-age={STATIC_TTL}, immutable",
}
return (body, headers, 200)
else:
os.remove(path)
except Exception:
pass
self._misses += 1
return None
def set(self, url: str, body: bytes, headers: dict, status: int):
"""Store static file on disk."""
if not is_static_path(url):
return
if status != 200:
return
path = self._cache_path(url)
try:
with self._lock:
with open(path, "wb") as f:
f.write(body)
except Exception as e:
logger.debug(f"Cache write failed: {e}")
def get_stats(self) -> dict: def get_stats(self) -> dict:
with self._lock: with self._lock:
return { try:
"size": len(self._cache), files = os.listdir(CACHE_DIR)
"capacity": self.capacity, total_size = sum(
"usage_pct": round(len(self._cache) / self.capacity * 100, 1) if self.capacity else 0, os.path.getsize(os.path.join(CACHE_DIR, f))
} for f in files
if os.path.isfile(os.path.join(CACHE_DIR, f))
)
except Exception:
files = []
total_size = 0
return {
"size": len(files),
"capacity": "unlimited",
"usage_pct": round(total_size / (1024 * 1024), 1),
"hits": self._hits,
"misses": self._misses,
}
# TTL 策略:不同路径不同缓存时间 def _guess_content_type(url: str) -> str:
PATH_TTL = { url_lower = url.lower()
"/": 30, # 首页 30s if url_lower.endswith(".css"):
"/works": 60, # 作品列表 60s return "text/css; charset=utf-8"
"/chapters": 120, # 章节内容 120s if url_lower.endswith(".js"):
"/series": 60, # 系列 60s return "application/javascript; charset=utf-8"
"/collections": 60, if url_lower.endswith(".png"):
"/tags": 60, return "image/png"
"/users": 30, if url_lower.endswith(".jpg") or url_lower.endswith(".jpeg"):
"/pseuds": 30, return "image/jpeg"
"/bookmarks": 60, if url_lower.endswith(".gif"):
"/skins": 300, # CSS 皮肤缓存 5 分钟 return "image/gif"
"/stylesheets": 300, if url_lower.endswith(".svg"):
"/images": 600, # 图片缓存 10 分钟 return "image/svg+xml"
"/media": 600, if url_lower.endswith(".ico"):
"/javascripts": 300, return "image/x-icon"
"/api": 15, # API 响应 15s if url_lower.endswith(".woff"):
"/external_links": 30, return "font/woff"
# Default: 30s if url_lower.endswith(".woff2"):
} return "font/woff2"
if url_lower.endswith(".ttf"):
return "font/ttf"
if url_lower.endswith(".webp"):
return "image/webp"
return "application/octet-stream"
def get_ttl_for_path(path: str) -> int: def get_ttl_for_path(path: str) -> int:
"""Determine cache TTL based on URL path.""" """Only used for static path TTL. Returns 0 for dynamic paths."""
for prefix, ttl in PATH_TTL.items(): return STATIC_TTL if is_static_path(path) else 0
if path.startswith(prefix):
return ttl
return 30 # default
# 全局缓存实例 # ─── Singleton ──────────────────────────────────────────────────────────────
_cache: Optional[LRUCache] = None
_cache: Optional[StaticCache] = None
def get_cache() -> LRUCache: def get_cache() -> StaticCache:
global _cache global _cache
if _cache is None: if _cache is None:
_cache = LRUCache(capacity=5000, default_ttl=30) _cache = StaticCache()
return _cache return _cache

View File

@@ -0,0 +1,8 @@
/*!
* JavaScript Cookie v2.2.1
* https://github.com/js-cookie/js-cookie
*
* Copyright 2006, 2015 Klaus Hartl & Fagner Brack
* Released under the MIT license
*/
!function(e){var n;if("function"==typeof define&&define.amd&&(define(e),n=!0),"object"==typeof exports&&(module.exports=e(),n=!0),!n){var t=window.Cookies,o=window.Cookies=e();o.noConflict=function(){return window.Cookies=t,o}}}(function(){function e(){for(var e=0,n={};e<arguments.length;e++){var t=arguments[e];for(var o in t)n[o]=t[o]}return n}function n(e){return e.replace(/(%[0-9A-Z]{2})+/g,decodeURIComponent)}return function t(o){function r(){}function i(n,t,i){if("undefined"!=typeof document){"number"==typeof(i=e({path:"/"},r.defaults,i)).expires&&(i.expires=new Date(1*new Date+864e5*i.expires)),i.expires=i.expires?i.expires.toUTCString():"";try{var c=JSON.stringify(t);/^[\{\[]/.test(c)&&(t=c)}catch(e){}t=o.write?o.write(t,n):encodeURIComponent(String(t)).replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g,decodeURIComponent),n=encodeURIComponent(String(n)).replace(/%(23|24|26|2B|5E|60|7C)/g,decodeURIComponent).replace(/[\(\)]/g,escape);var f="";for(var u in i)i[u]&&(f+="; "+u,!0!==i[u]&&(f+="="+i[u].split(";")[0]));return document.cookie=n+"="+t+f}}function c(e,t){if("undefined"!=typeof document){for(var r={},i=document.cookie?document.cookie.split("; "):[],c=0;c<i.length;c++){var f=i[c].split("="),u=f.slice(1).join("=");t||'"'!==u.charAt(0)||(u=u.slice(1,-1));try{var a=n(f[0]);if(u=(o.read||o)(u,a)||n(u),t)try{u=JSON.parse(u)}catch(e){}if(r[a]=u,e===a)break}catch(e){}}return e?r[e]:r}}return r.set=i,r.get=function(e){return c(e,!1)},r.getJSON=function(e){return c(e,!0)},r.remove=function(n,t){i(n,"",e(t,{expires:-1}))},r.defaults={},r.withConverter=t,r}(function(){})});

152
cache/0ff52d5b7b9f8180daa067cf312b4baf vendored Normal file
View File

@@ -0,0 +1,152 @@
/* MEDIA: only screen and (max-width: 62em), handheld ENDMEDIA */
/* 04 region dashboard */
#dashboard {
clear: both;
float: none;
margin: 1% 3.5%;
max-width: 100%;
padding: 0;
width: auto;
}
#dashboard, #dashboard.own {
border-bottom: 10px solid #900;
border-top: 10px solid #900;
padding: 0.5em 0;
border-radius: 0.25em;
}
#dashboard ul {
border: none;
display: inline;
padding: 0;
text-align: left;
}
#dashboard li {
display: inline;
}
#dashboard a, #dashboard span {
display: inline-block;
margin: 0.25em 0;
}
#dashboard .secondary {
background: #eee;
padding: 0.375em 0 0.625em;
box-shadow: inset 2px 2px 5px #bbb;
}
#dashboard .secondary a {
margin: 0.125em 0;
}
#dashboard .landmark {
clear: none;
float: left;
}
/* 05 region main */
#main, #main.dashboard {
float: none;
margin: auto;
padding-left: 3.5%;
padding-right: 3.5%;
width: auto;
}
/* 07 interactions */
form.single input[type="text"] {
width: 100%;
box-sizing: border-box;
}
form.single input[type="submit"] {
margin-top: 0.375em;
}
form.single span.submit {
display: block;
text-align: right;
}
form.single ul.autocomplete {
display: block;
}
form.single .autocomplete li.input {
margin-right: 0;
}
/* 08 actions */
.javascript .work.navigation .secondary {
width: 100%;
box-sizing: border-box;
}
/* If we use display without the .expanded qualifier, it will override .hidden's
display: none and secondary will always be open. */
.javascript .work.navigation .expanded + .secondary, .javascript .work.navigation .secondary li, .javascript .work.navigation .secondary p, .javascript .work.navigation .secondary a {
display: block;
}
.javascript .work.navigation .secondary p {
padding-block-start: 0.375em;
}
.javascript .work.navigation .secondary select {
max-width: 100%;
min-width: auto;
}
.javascript .work.navigation .secondary a {
height: 100%;
min-height: 1.286em;
padding-inline: 0.5em;
white-space: normal;
}
/* 16 zone system */
.logged-in .splash > .module {
width: 48.5%
}
.logged-in .splash > div:nth-of-type(odd) {
margin-left: 0;
margin-right: 1.5%;
}
.logged-in .splash > div:nth-of-type(even) {
margin-left: 1.5%;
margin-right: 0;
}
/* 18 zone searchbrowse */
form.filters {
width: auto;
min-width: 23%;
max-width: 24%;
}
.filters fieldset {
margin-right: 0;
}
form.filters dl {
margin-left: 0.25em;
margin-right: 0.25em;
}
/* 21 userstuff */
#workskin {
margin: auto 1.5%;
}

BIN
cache/17342e82684c267ba4f1e531738eb513 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
cache/2f7d84701e69c2978451a4693dbf6b35 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
function setupFilterToggles(){var e=$j(".filters").find("dt.filter-toggle");e.each(function(){var e=$j(this).next().attr("id");$j(this).wrapInner('<button type="button" class="expander" aria-expanded="false" aria-controls="'+e+'"></button>')}),$j("dt.tags button").on("click",function(){"false"==$j(this).attr("aria-expanded")?$j(this).attr("aria-expanded","true"):$j(this).attr("aria-expanded","false")})}function showFilters(){var e=$j(".filters").find("dd.expandable");e.each(function(e,t){var a=$j(t).find("input").filter('[value]:not([value=""])'),r=$j(t).attr("id"),n=$j("#toggle_"+r),i=$j('[aria-controls="'+r+'"]');a.each(function(e,a){$j(a).is(':checked, [type="text"]')&&($j(t).removeClass("hidden"),$j(n).removeClass("collapsed").addClass("expanded"),$j(i).attr("aria-expanded","true"))})})}function setupNarrowScreenFilters(){var e=$j("form.filters"),t=$j("#outer"),a=$j("#go_to_filters"),r=$j("#leave_filters");a.click(function(a){a.preventDefault(),e.removeClass("narrow-hidden"),t.addClass("filtering"),e.find(":focusable").first().focus(),e.trap()}),r.click(function(r){r.preventDefault(),t.removeClass("filtering"),e.addClass("narrow-hidden"),a.focus()})}$j(document).ready(function(){setupFilterToggles(),showFilters(),setupNarrowScreenFilters()});

76
cache/331a4a4d15181598c366618812c89376 vendored Normal file
View File

@@ -0,0 +1,76 @@
/* MEDIA: speech */
.landmark {
opacity: 1;
height: auto;
width: auto;
font-size: 100%;
line-height: 0;
color: black;
}
a.tag {
speak: no-punctuation;
}
em {
voice-stress: moderate;
}
strong, .warnings, .caution {
voice-stress: strong;
}
cite {
voice-rate: 95%;
}
acronym {
speak: spell-out;
}
abbr {
content: attr(title);
}
code, kbd, tt, samp {
voice-rate: 95%;
}
.userstuff {
speak-numeral: continuous;
}
.meta {
voice-rate: 110%;
}
.blurb {
voice-rate: 105%;
}
.userstuff h1, .userstuff h2.title {
voice-rate: 90%;
pause: 5ms 10ms;
}
.chapter {
pause: 10ms 10ms;
}
.userstuff dt {
voice-balance: leftwards;
}
.userstuff dd {
voice-balance: rightwards;
}
.navigation {
voice-stress: moderate;
speak: no-punctuation;
}
.splash .browse li a:before {
speak: none;
}

View File

@@ -0,0 +1 @@
!function(o){"use strict";var n="[data-toggle=dropdown]",t=function(n){var t=o(n).on("click.dropdown.data-api",this.toggle);o("html").on("click.dropdown.data-api",(function(){t.parent().removeClass("open")}))};function e(){o(n).each((function(){d(o(this)).removeClass("open")}))}function d(n){var t,e=n.attr("data-target");return e||(e=(e=n.attr("href"))&&/#/.test(e)&&e.replace(/.*(?=#[^\s]*$)/,"")),(t=e&&o(e))&&t.length||(t=n.parent()),t}t.prototype={constructor:t,toggle:function(n){var t,r,i=o(this);if(!i.is(".disabled, :disabled"))return r=(t=d(i)).hasClass("open"),e(),r?(t.children("ul").hide(),i.blur()):(t.toggleClass("open").children("ul").removeAttr("style"),i.focus()),i.focus(),!1},keydown:function(t){var e,r,i,a,s;if(/(38|40|27)/.test(t.keyCode)&&(e=o(this),t.preventDefault(),t.stopPropagation(),!e.is(".disabled, :disabled"))){if(!(a=(i=d(e)).hasClass("open"))||a&&27==t.keyCode)return 27==t.which&&i.find(n).focus(),e.click();(r=o("ul.menu li:not(.divider):visible a",i)).length&&(s=r.index(r.filter(":focus")),38==t.keyCode&&s>0&&s--,40==t.keyCode&&s<r.length-1&&s++,~s||(s=0),r.eq(s).focus())}}};var r=o.fn.dropdown;o.fn.dropdown=function(n){return this.each((function(){var e=o(this),d=e.data("dropdown");d||e.data("dropdown",d=new t(this)),"string"==typeof n&&d[n].call(e)}))},o.fn.dropdown.Constructor=t,o.fn.dropdown.noConflict=function(){return o.fn.dropdown=r,this},o(document).on("click.dropdown.data-api",e).on("click.dropdown.data-api",".dropdown form",(function(o){o.stopPropagation()})).on("click.dropdown-menu",(function(o){o.stopPropagation()})).on("click.dropdown.data-api",n,t.prototype.toggle).on("keydown.dropdown.data-api",n+", ul.menu",t.prototype.keydown).on("mouseenter",".dropdown",(function(n){var t=o(this);t.siblings(".open").length&&t.children("ul").hide()})).on("mouseleave",".dropdown",(function(n){o(this).children("ul").removeAttr("")}))}(window.jQuery);

View File

@@ -0,0 +1,8 @@
/*!
Copyright (c) 2011, 2012 Julien Wajsberg <felash@gmail.com>
All rights reserved.
Official repository: https://github.com/julienw/jquery-trap-input
License is there: https://github.com/julienw/jquery-trap-input/blob/master/LICENSE
This is version 1.2.0.
*/(function(e,t){function r(e){if(e.keyCode===9){var t=!!e.shiftKey;if(i(this,e.target,t)){e.preventDefault();e.stopPropagation()}}}function i(e,t,n){var r=a(e),i=t,s,o,u,f;do{s=r.index(i);o=s+1;u=s-1;f=r.length-1;switch(s){case-1:return false;case 0:u=f;break;case f:o=0;break}if(n){o=u}i=r.get(o);if(!i||i===t){return true}try{i.focus()}catch(l){return true}}while(r.length>1&&t===t.ownerDocument.activeElement);return true}function s(){return this.tabIndex>0}function o(){return!this.tabIndex}function u(e,t){return e.t-t.t||e.i-t.i}function a(t){var n=e(t);var r=[],i=0;h.enable&&h.enable();n.find("a[href], link[href], [draggable=true], [contenteditable=true], :input:enabled, [tabindex=0]").filter(":visible").filter(o).each(function(e,t){r.push({v:t,t:0,i:i++})});n.find("[tabindex]").filter(":visible").filter(s).each(function(e,t){r.push({v:t,t:t.tabIndex,i:i++})});h.disable&&h.disable();r=e.map(r.sort(u),function(e){return e.v});return e(r)}function f(){this.keydown(r);this.data(n,true);return this}function l(){this.unbind("keydown",r);this.removeData(n);return this}function c(){return!!this.data(n)}var n="trap.isTrapping";e.fn.extend({trap:f,untrap:l,isTrapping:c});var h={};if(e.find.find&&e.find.attr!==e.attr){(function(){function i(e){var r=e.getAttributeNode(n);return r&&r.specified?parseInt(r.value,10):t}function s(){r[n]=r.tabIndex=i}function o(){delete r[n];delete r.tabIndex}var n="tabindex";var r=e.expr.attrHandle;h={enable:s,disable:o}})()}})(jQuery);

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2007 Ariel Flesler - aflesler ○ gmail • com | https://github.com/flesler
* Licensed under MIT
* @author Ariel Flesler
* @version 2.1.2
*/
;(function(f){"use strict";"function"===typeof define&&define.amd?define(["jquery"],f):"undefined"!==typeof module&&module.exports?module.exports=f(require("jquery")):f(jQuery)})(function($){"use strict";function n(a){return!a.nodeName||-1!==$.inArray(a.nodeName.toLowerCase(),["iframe","#document","html","body"])}function h(a){return $.isFunction(a)||$.isPlainObject(a)?a:{top:a,left:a}}var p=$.scrollTo=function(a,d,b){return $(window).scrollTo(a,d,b)};p.defaults={axis:"xy",duration:0,limit:!0};$.fn.scrollTo=function(a,d,b){"object"=== typeof d&&(b=d,d=0);"function"===typeof b&&(b={onAfter:b});"max"===a&&(a=9E9);b=$.extend({},p.defaults,b);d=d||b.duration;var u=b.queue&&1<b.axis.length;u&&(d/=2);b.offset=h(b.offset);b.over=h(b.over);return this.each(function(){function k(a){var k=$.extend({},b,{queue:!0,duration:d,complete:a&&function(){a.call(q,e,b)}});r.animate(f,k)}if(null!==a){var l=n(this),q=l?this.contentWindow||window:this,r=$(q),e=a,f={},t;switch(typeof e){case "number":case "string":if(/^([+-]=?)?\d+(\.\d+)?(px|%)?$/.test(e)){e= h(e);break}e=l?$(e):$(e,q);case "object":if(e.length===0)return;if(e.is||e.style)t=(e=$(e)).offset()}var v=$.isFunction(b.offset)&&b.offset(q,e)||b.offset;$.each(b.axis.split(""),function(a,c){var d="x"===c?"Left":"Top",m=d.toLowerCase(),g="scroll"+d,h=r[g](),n=p.max(q,c);t?(f[g]=t[m]+(l?0:h-r.offset()[m]),b.margin&&(f[g]-=parseInt(e.css("margin"+d),10)||0,f[g]-=parseInt(e.css("border"+d+"Width"),10)||0),f[g]+=v[m]||0,b.over[m]&&(f[g]+=e["x"===c?"width":"height"]()*b.over[m])):(d=e[m],f[g]=d.slice&& "%"===d.slice(-1)?parseFloat(d)/100*n:d);b.limit&&/^\d+$/.test(f[g])&&(f[g]=0>=f[g]?0:Math.min(f[g],n));!a&&1<b.axis.length&&(h===f[g]?f={}:u&&(k(b.onAfterFirst),f={}))});k(b.onAfter)}})};p.max=function(a,d){var b="x"===d?"Width":"Height",h="scroll"+b;if(!n(a))return a[h]-$(a)[b.toLowerCase()]();var b="client"+b,k=a.ownerDocument||a.document,l=k.documentElement,k=k.body;return Math.max(l[h],k[h])-Math.min(l[b],k[b])};$.Tween.propHooks.scrollLeft=$.Tween.propHooks.scrollTop={get:function(a){return $(a.elem)[a.prop]()}, set:function(a){var d=this.get(a);if(a.options.interrupt&&a._last&&a._last!==d)return $(a.elem).stop();var b=Math.round(a.now);d!==b&&($(a.elem)[a.prop](b),a._last=this.get(a))}};return p});

641
cache/4853d7f210093728ff9ad1ca4d54f9e4 vendored Normal file
View File

@@ -0,0 +1,641 @@
// Place your application-specific JavaScript functions and classes here
// This file is automatically included by javascript_include_tag :defaults
//things to do when the page loads
$j(document).ready(function() {
setupToggled();
if ($j('form#work-form')) { hideFormFields(); }
hideHideMe();
showShowMe();
handlePopUps();
attachCharacterCounters();
setupAccordion();
setupDropdown();
updateCachedTokens();
// add clear to items on the splash page in older browsers
$j('.splash').children('div:nth-of-type(odd)').addClass('odd');
// make Share buttons on works and own bookmarks visible
$j('.actions').children('.share').removeClass('hidden');
// make Approve buttons on inbox items visible
$j('#inbox-form, .messages').find('.unreviewed').find('.review').find('a').removeClass('hidden');
prepareDeleteLinks();
thermometer();
$j('body').addClass('javascript');
});
///////////////////////////////////////////////////////////////////
// Autocomplete
///////////////////////////////////////////////////////////////////
function get_token_input_options(self) {
return {
searchingText: self.data('autocomplete-searching-text'),
hintText: self.data('autocomplete-hint-text'),
noResultsText: self.data('autocomplete-no-results-text'),
minChars: self.data('autocomplete-min-chars'),
queryParam: "term",
preventDuplicates: true,
tokenLimit: self.data('autocomplete-token-limit'),
liveParams: self.data('autocomplete-live-params'),
makeSortable: self.data('autocomplete-sortable')
};
}
// Look for autocomplete_options in application helper and throughout the views to
// see how to use this!
var input = $j('input.autocomplete');
if (input.livequery) {
jQuery(function($) {
$('input.autocomplete').livequery(function(){
var self = $(this);
var token_input_options = get_token_input_options(self);
var method;
try {
method = $.parseJSON(self.data('autocomplete-method'));
} catch (err) {
method = self.data('autocomplete-method');
}
self.tokenInput(method, token_input_options);
});
});
}
///////////////////////////////////////////////////////////////////
// expand, contract, shuffle
jQuery(function($){
$(".expand").each(function(){
// start by hiding the list in the page
list = $($(this).data("action-target"));
if (!list.data("force-expand") || list.children().size() > 25 || list.data("force-contract")) {
list.hide();
$(this).show();
} else {
// show the shuffle and contract button only
$(this).nextAll(".shuffle").show();
$(this).next(".contract").show();
}
// set up click event to expand the list
$(this).click(function(event){
list = $($(this).data("action-target"));
list.show();
// show the contract & shuffle buttons and hide us
$(this).next(".contract").show();
$(this).nextAll(".shuffle").show();
$(this).hide();
});
});
$(".contract").each(function(){
$(this).click(function(event){
// hide the list when clicked
list = $($(this).data("action-target"));
list.hide();
// show the expand and shuffle buttons and hide us
$(this).prev(".expand").show();
$(this).nextAll(".shuffle").hide();
$(this).hide();
});
});
$(".shuffle").each(function(){
// shuffle the list's children when clicked
$(this).click(function(event){
list = $($(this).data("action-target"));
list.children().shuffle();
});
});
$(".expand_all").each(function(){
target = "." + $(this).data("target-class");
$(this).click(function(event) {
$(this).closest(target).find(".expand").click();
});
});
$(".contract_all").each(function(){
target = "." + $(this).data("target-class");
$(this).click(function(event) {
$(this).closest(target).find(".contract").click();
});
});
});
// check all or none within the parent fieldset, optionally with a string to match on the id attribute of the checkboxes
// stored in the "data-checkbox-id-filter" attribute on the all/none links.
// allow for some flexibility by checking the next and previous fieldset if the checkboxes aren't in this one
jQuery(function($){
$('.check_all').each(function(){
$(this).click(function(event){
var filter = $(this).data('checkbox-id-filter');
var checkboxes;
if (filter) {
checkboxes = $(this).closest('fieldset').find('input[id*="' + filter + '"][type="checkbox"]');
} else {
checkboxes = $(this).closest("fieldset").find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").next().find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").prev().find(':checkbox');
}
}
}
checkboxes.prop('checked', true);
event.preventDefault();
});
});
$('.check_none').each(function(){
$(this).click(function(event){
var filter = $(this).data('checkbox-id-filter');
var checkboxes;
if (filter) {
checkboxes = $(this).closest('fieldset').find('input[id*="' + filter + '"][type="checkbox"]');
} else {
checkboxes = $(this).closest("fieldset").find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").next().find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").prev().find(':checkbox');
}
}
}
checkboxes.prop('checked', false);
event.preventDefault();
});
});
});
// Set up open and close toggles for a given object
// Typical setup (this will leave the toggled item open for users without javascript but hide the controls from them):
// <a class="foo_open hidden">Open Foo</a>
// <div id="foo" class="toggled">
// foo!
// <a class="foo_close hidden">Close</a>
// </div>
//
// Notes:
// - The open button CANNOT be inside the toggled div, the close button can be (but doesn't have to be)
// - You can have multiple open and close buttons for the same div since those are labeled with classes
// - You don't have to use div and a, those are just examples. Anything you put the toggled and _open/_close classes on will work.
// - If you want the toggled item not to be visible to users without JavaScript by default, add the class "hidden" to the toggled item as well.
// (and you can then add an alternative link for them using <noscript>)
// - Generally reserved for toggling complex elements like bookmark forms and challenge sign-ups; for simple elements like lists use setupAccordion.
function setupToggled(){
$j('.toggled').filter(function(){
return $j(this).closest('.userstuff').length === 0;
}).each(function(){
var node = $j(this);
var open_toggles = $j('.' + node.attr('id') + "_open");
var close_toggles = $j('.' + node.attr('id') + "_close");
if (node.hasClass('open')) {
close_toggles.each(function(){$j(this).show();});
open_toggles.each(function(){$j(this).hide();});
} else {
node.hide();
close_toggles.each(function(){$j(this).hide();});
open_toggles.each(function(){$j(this).show();});
}
open_toggles.each(function(){
$j(this).click(function(e){
if ($j(this).attr('href') == '#') {e.preventDefault();}
node.show();
open_toggles.each(function(){$j(this).hide();});
close_toggles.each(function(){$j(this).show();});
});
});
close_toggles.each(function(){
$j(this).click(function(e){
if ($j(this).attr('href') == '#') {e.preventDefault();}
node.hide();
close_toggles.each(function(){$j(this).hide();});
open_toggles.each(function(){$j(this).show();});
});
});
});
}
function hideHideMe() {
$j('.hideme').each(function() { $j(this).hide(); });
}
function showShowMe() {
$j('.showme').each(function() { $j(this).show(); });
}
function handlePopUps() {
$j("a[data_popup]").click(function(event, element) {
if (event.stopped) return;
window.open($j(element).attr('href'));
event.stop();
});
}
// used in nested form fields for deleting a nested resource
// see prompt form for example
function remove_section(link, class_of_section_to_remove) {
$j(link).siblings(":input[type=hidden]").val("1"); // relies on the "_destroy" field being the nearest hidden field
var section = $j(link).closest("." + class_of_section_to_remove);
section.find(".required input, .required textarea").each(function(index) {
var element = eval('validation_for_' + $j(this).attr('id'));
element.disable();
});
section.hide();
}
// used with nested form fields for dynamically stuffing in an extra partial
// see challenge signup form and prompt form for an example
function add_section(link, nested_model_name, content) {
// get the right new_id which should be in a div with class "last_id" at the bottom of
// the nearest section
var last_id = parseInt($j(link).parent().siblings('.last_id').last().html());
var new_id = last_id + 1;
var regexp = new RegExp("new_" + nested_model_name, "g");
content = content.replace(regexp, new_id);
// kludgy: show the hidden remove_section link (we don't want it showing for non-js users)
content = content.replace('class="hidden showme"', '');
$j(link).parent().before(content);
}
// An attempt to replace the various work form toggle methods with a more generic one
function toggleFormField(element_id) {
var ticky = $j('#' + element_id + '-show');
if (ticky.is(':checked')) {
$j('#' + element_id).removeClass('hidden');
}
else {
$j('#' + element_id).addClass('hidden');
if (element_id != 'chapters-options' && element_id != 'backdate-options') {
$j('#' + element_id).find(':input[type!="hidden"]').each(function(index, d) {
if ($j(d).attr('type') == "checkbox") {$j(d).attr('checked', false);}
else {$j(d).val('');}
});
}
}
// We want to check this whether the ticky is checked or not
if (element_id == 'chapters-options') {
var item = document.getElementById('work_wip_length');
if (item.value == 1 || item.value == '1') {item.value = '?';}
else {item.value = 1;}
}
}
// Hides expandable form field options if Javascript is enabled
function hideFormFields() {
if ($j('form#work-form') != null) {
var toHide = ['#co-authors-options', '#front-notes-options', '#end-notes-options', '#chapters-options',
'#parent-options', '#series-options', '#backdate-options', '#override_tags-options'];
$j.each(toHide, function(index, name) {
if ($j(name)) {
if (!($j(name + '-show').is(':checked'))) { $j(name).addClass('hidden'); }
}
});
$j('form#work-form').className = $j('form#work-form').className;
}
}
// Hides the extra checkbox fields in prompt form
function hideField(id) {
$j('#' + id).toggle();
}
function attachCharacterCounters() {
var countFn = function() {
var counter = (function(input) {
/* Character-counted inputs do not always have the same hierarchical relationship
to their associated counter elements in the DOM, and some cc-inputs have
duplicate ids. So search for the input's associated counter element first by id,
then by checking the input's siblings, then by checking its cousins. */
var cc = $j('.character_counter [id='+input.attr('id')+'_counter]');
if (cc.length === 1) { return cc; } // id search, use attribute selector rather
// than # to check for duplicate ids
cc = input.nextAll('.character_counter').first().find('.value'); // sibling search
if (cc.length) { return cc; }
var parent = input.parent(); // 2 level cousin search
for (var i = 0; i < 2; i++) {
cc = parent.nextAll('.character_counter').find('.value');
if (cc.length) { return cc; }
parent = parent.parent();
}
return $j(); // return empty jquery element if search found nothing
})($j(this)),
max = parseInt(counter.attr('data-maxlength'), 10),
val = $j(this).val().replace(/\r\n/g,'\n').replace(/\r|\n/g,'\r\n'),
remaining = max - val.length;
counter.html(remaining).attr("aria-valuenow", remaining);
};
$j(document).on('keyup keydown mouseup mousedown change', '.observe_textlength', countFn);
$j('.observe_textlength').each(countFn);
}
// add attributes that are only needed in the primary menus and when JavaScript is enabled
function setupDropdown(){
$j('#header').find('.dropdown').attr("aria-haspopup", true);
$j('#header').find('.dropdown, .dropdown .actions').children('a').attr({
'class': 'dropdown-toggle',
'data-toggle': 'dropdown',
'data-target': '#'
});
$j('.dropdown').find('.menu').addClass("dropdown-menu");
}
// Accordion-style collapsible widgets
// The pane element can be shown or hidden using the expander (link)
// Apply hidden to the pane element if it shouldn't be visible when JavaScript is disabled
// Typical set up:
// <li aria-haspopup="true">
// <a href="#">Expander</a>
// <div class="expandable">
// foo!
// </div>
// </li>
function setupAccordion() {
$j(".expandable").filter(function() {
return $j(this).closest(".userstuff").length === 0;
}).each(function() {
var pane = $j(this);
// hide the pane element if it's not hidden by default
if ( !pane.hasClass("hidden") ) {
pane.addClass("hidden");
};
// make the expander visible
// add the default collapsed state
// make it do the expanding and collapsing
pane.prev().removeClass("hidden").addClass("collapsed").click(function(e) {
var expander = $j(this);
if (expander.attr('href') == '#') {
e.preventDefault();
};
// change the classes upon clicking the expander
expander.toggleClass("collapsed").toggleClass("expanded").next().toggleClass("hidden");
});
});
}
// Remove the /confirm_delete portion of delete links so user who have JS enabled will
// be able to delete items via hyperlink (per rails/jquery-ujs) rather than a dedicated
// form page.
function prepareDeleteLinks() {
$j('a[href$="/confirm_delete"][data-confirm]').each(function(){
this.href = this.href.replace(/\/confirm_delete$/, "");
$j(this).attr("data-method", "delete");
});
// Removing non-default orphan_account pseuds from works
$j('a[href$="/confirm_remove_pseud"][data-confirm]').each(function() {
this.href = this.href.replace(/\/confirm_remove_pseud$/, "/remove_pseud");
$j(this).attr("data-method", "put");
});
// For purging assignments in gift exchanges. This is only on one page and easy to
// check, so don't worry about adding a fallback data-confirm message.
$j('a[href$="/confirm_purge"][data-confirm]').each(function() {
this.href = this.href.replace(/\/confirm_purge$/, "/purge");
$j(this).attr("data-method", "post");
});
}
/// Kudos
$j(document).ready(function() {
$j('input#kudo_submit').on("click", function(event) {
event.preventDefault();
$j.ajax({
type: 'POST',
url: '/kudos.js',
data: jQuery('#new_kudo').serialize(),
error: function(jqXHR, textStatus, errorThrown) {
var msg = 'Sorry, we were unable to save your kudos';
// When we hit the rate limit, the response from Rack::Attack is a plain text 429.
if (jqXHR.status == "429") {
msg = "Sorry, you can't leave more kudos right now. Please try again in a few minutes.";
} else {
var data = $j.parseJSON(jqXHR.responseText);
if (data.error_message) {
msg = data.error_message;
}
}
$j('#kudos_message').addClass('kudos_error').text(msg);
},
success: function(data) {
$j('#kudos_message').addClass('notice').text('Thank you for leaving kudos!');
}
});
});
// Scroll to the top of the comments section when loading additional pages via Ajax in comment pagination.
$j('#comments_placeholder').on('click.rails', '.pagination a[data-remote]', function(e){
$j.scrollTo('#comments_placeholder');
});
// Scroll to the top of the comments section when loading comments via AJAX
$j("#show_comments_link_top").on('click.rails', 'a[href*="show_comments"]', function(e){
$j.scrollTo('#comments');
});
});
// For simple forms that appear to toggle between creating and destroying records
// e.g. favorite tags, subscriptions
// <form> needs ajax-create-destroy class, data-create-value, data-destroy-value
// data-create-value: text of the button for creating, e.g. Favorite, Subscribe
// data-destroy-value: text of button for destroying, e.g. Unfavorite, Unsubscribe
// controller needs item_id and item_success_message for save success and
// item_success_message for destroy success
$j(document).ready(function() {
$j('form.ajax-create-destroy').on("click", function(event) {
event.preventDefault();
var form = $j(this);
var formAction = form.attr('action');
var formSubmit = form.find('[type="submit"]');
var createValue = form.data('create-value');
var destroyValue = form.data('destroy-value');
var flashContainer = $j('.flash');
$j.ajax({
type: 'POST',
url: formAction,
data: form.serialize(),
dataType: 'json',
success: function(data) {
flashContainer.removeClass('error').empty();
if (data.item_id) {
flashContainer.addClass('notice').html(data.item_success_message);
formSubmit.val(destroyValue);
form.append('<input name="_method" type="hidden" value="delete">');
form.attr('action', formAction + '/' + data.item_id);
} else {
flashContainer.addClass('notice').html(data.item_success_message);
formSubmit.val(createValue);
form.find('input[name="_method"]').remove();
form.attr('action', formAction.replace(/\/\d+/, ''));
}
},
error: function(xhr, textStatus, errorThrown) {
flashContainer.empty();
flashContainer.addClass('error notice');
try {
jQuery.parseJSON(xhr.responseText);
} catch (e) {
flashContainer.append("We're sorry! Something went wrong.");
return;
}
$j.each(jQuery.parseJSON(xhr.responseText).errors, function(index, error) {
flashContainer.append(error + " ");
});
}
});
});
});
// For simple forms that update or destroy records and remove them from a listing
// e.g. delete from history, mark as read, delete invitation request
// <form> needs ajax-remove class
// controller needs item_success_message
$j(document).ready(function() {
$j('form.ajax-remove').on("click", function(event) {
event.preventDefault();
var form = $j(this);
var formAction = form.attr('action');
// The record we're removing is probably in a list, but might be in a table
if (form.closest('li.group').length !== 0) {
formParent = form.closest('li.group');
} else { formParent = form.closest('tr'); };
// The admin div does not hold a flash container
var parentContainer = formParent.closest('div:not(.admin)');
var flashContainer = parentContainer.find('.flash');
$j.ajax({
type: 'POST',
url: formAction,
data: form.serialize(),
dataType: 'json',
success: function(data) {
flashContainer.removeClass('error').empty();
flashContainer.addClass('notice').html(data.item_success_message);
},
error: function(xhr, textStatus, errorThrown) {
flashContainer.empty();
flashContainer.addClass('error notice');
try {
jQuery.parseJSON(xhr.responseText);
} catch (e) {
flashContainer.append("We're sorry! Something went wrong.");
return;
}
$j.each(jQuery.parseJSON(xhr.responseText).errors, function(index, error) {
flashContainer.append(error + " ");
});
}
});
$j(document).ajaxSuccess(function() {
formParent.slideUp(function() {
$j(this).remove();
});
});
});
});
// FUNDRAISING THERMOMETER adapted from http://jsfiddle.net/GeekyJohn/vQ4Xn/
function thermometer() {
var banners = $j('.announcement').filter(function(){
return $j(this).closest('.userstuff').length === 0;
});
banners.has('.goal').each(function(){
var banner_content = $j(this).find('.userstuff');
banner_goal_text = banner_content.find('span.goal').html();
banner_progress_text = banner_content.find('span.progress').html();
if ($j(this).find('span.goal').hasClass('stretch')){
stretch = true
} else { stretch = false }
goal_amount = parseFloat(banner_goal_text.replace(/\.(?![0-9])|[^\.0-9]/g, ''));
progress_amount = parseFloat(banner_progress_text.replace(/\.(?![0-9])|[^\.0-9]/g, ''));
percentage_amount = Math.min( Math.round(progress_amount / goal_amount * 1000) / 10, 100);
// add thermometer markup (with amounts)
banner_content.append('<div class="thermometer-content"><div class="thermometer"><div class="track"><div class="goal"><span class="amount">' + banner_goal_text +'</span></div><div class="progress"><span class="amount">' + banner_progress_text + '</span></div></div></div></div>');
// set the progress indicator
// darker green for over 100% stretch goals
// green for 100%
// yellow-green for 85-99%
// yellow for 30-84%
// orange for 0-29%
if ( stretch == true ) {
banner_content.find('div.track').css({
'background': '#8eb92a',
'background-image': 'linear-gradient(to bottom, #bfd255 0%, #8eb92a 50%, #72aa00 51%, #9ecb2d 100%)'
});
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#4d7c10',
'background-image': 'linear-gradient(to bottom, #6e992f 0%, #4d7c10 50%, #3b7000 51%, #5d8e13 100%)'
});
} else if (percentage_amount >= 100) {
banner_content.find('div.progress').css({
'width': '100%',
'background': '#8eb92a',
'background-image': 'linear-gradient(to bottom, #bfd255 0%, #8eb92a 50%, #72aa00 51%, #9ecb2d 100%)'
});
} else if (percentage_amount >= 85) {
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#d2e638',
'background-image': 'linear-gradient(to bottom, #e6f0a3 0%, #d2e638 50%, #c3d825 51%, #dbf043 100%)'
});
} else if (percentage_amount >= 30) {
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#fccd4d',
'background-image': 'linear-gradient(to bottom, #fceabb 0%, #fccd4d 50%, #f8b500 51%, #fbdf93 100%)'
});
} else {
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#f17432',
'background-image': 'linear-gradient(to bottom, #feccb1 0%, #f17432 50%, #ea5507 51%, #fb955e 100%)'
});
}
});
}
function updateCachedTokens() {
// we only do full page caching when users are logged out
if ($j('#small_login').length > 0) {
$j.getJSON("/token_dispenser.json", function( data ) {
var token = data.token;
// set token on fields
$j('input[name=authenticity_token]').each(function(){
$j(this).attr('value', token);
});
$j('meta[name=csrf-token]').attr('content', token);
$j.event.trigger({ type: "loadedCSRF" });
});
} else {
$j.event.trigger({ type: "loadedCSRF" });
}
}

BIN
cache/648f0179eb5fdc03a5766870d2eb6550 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

143
cache/7bddd8efbc2b1a767d0f063375e157ce vendored Normal file
View File

@@ -0,0 +1,143 @@
/*==SANDBOX: All temporary, development, and unreviewed css lives here.
Rules will be incorporated into the main cascade by Front End before deploy.
If you've just coded up a new feature and put in some layout, put that layout here,
with a comment, and the Front End Santa will make your wishes come true.
Or if you've put in a fix and you've not yet read the Front End Docs?
Put your fix in here.
Examples:
/*Fix for issue 996
.bookmark .header {float:left;}
/*Draft layout for views/shiny/new by astolat.
It has to be purple, see wiki, ADT meeting 25
.shiny-happy-people table {display:table-cell;
background:purple; color:#555;}
/*Some helpful notes
0.875em/1.286 line height = 14px with 18px leading
0.643em = 9px, so {margin: 0.643em auto;} gives you a single blank line between block elements */
/* AO3-6869 -- Workaround for a layout bug in WebKit, triggered by `float` and LiveValidation's placeholder span */
form#new_abuse_report .footnote {
float: none;
margin-right: 0;
width: fit-content;
}
/* styling for AO3-3359 to remove top padding from the new button
and preserve the balance/symmetry of the page */
#new_work_search fieldset:first-of-type .submit {
padding-top: 0;
}
/* Issue 3243 needs nested ul to be indented in external_authors/claim
*/
.edit_external_author ul ul {
margin-left: 2.75em;
}
/* While implementing AO3-5987 it was suggested we move to non-JS share buttons.
* These are statically styled with CSS instead.
*
* Sourced from: https://sharingbuttons.io/
* See: https://github.com/otwcode/otwarchive/pull/3874#pullrequestreview-460459176
*/
a.resp-sharing-button__link,
.resp-sharing-button__icon {
display: inline-block;
}
a.resp-sharing-button__link,
a.resp-sharing-button__link:hover {
text-decoration: none;
color: #fff;
border: none;
}
.resp-sharing-button {
border-radius: 5px;
transition: 25ms ease-out;
padding: 0.5em 0.75em;
}
.resp-sharing-button__icon svg {
width: 1.25em;
height: 1.25em;
margin-right: 0.25em;
vertical-align: text-bottom;
overflow: visible;
}
/* Non solid icons get a stroke */
.resp-sharing-button__icon {
stroke: #fff;
fill: none;
}
/* Solid icons get a fill */
.resp-sharing-button__icon--solid {
fill: #fff;
stroke: none;
}
.resp-sharing-button--twitter {
background-color: #55acee;
}
.resp-sharing-button--twitter:hover,
a:focus .resp-sharing-button--twitter {
background-color: #2795e9;
}
.resp-sharing-button--tumblr {
background-color: #35465C;
}
.resp-sharing-button--tumblr:hover,
a:focus .resp-sharing-button--tumblr {
background-color: #222d3c;
}
.resp-sharing-button--bluesky {
background-color: #1185fe;
}
.resp-sharing-button--bluesky:hover,
a:focus .resp-sharing-button--bluesky {
background-color: #0168d6;
}
.resp-sharing-button--twitter {
background-color: #55acee;
border-color: #55acee;
}
.resp-sharing-button--twitter:hover,
.resp-sharing-button--twitter:active {
background-color: #2795e9;
border-color: #2795e9;
}
.resp-sharing-button--tumblr {
background-color: #35465C;
border-color: #35465C;
}
.resp-sharing-button--tumblr:hover,
.resp-sharing-button--tumblr:active {
background-color: #222d3c;
border-color: #222d3c;
}
.resp-sharing-button--bluesky {
background-color: #1185fe;
border-color: #1185fe;
}
.resp-sharing-button--bluesky:hover,
.resp-sharing-button--bluesky:active {
background-color: #0168d6;
border-color: #0168d6;
}

4491
cache/92cf3bcddf1191c3c04ceea2383c8ed9 vendored Normal file

File diff suppressed because it is too large Load Diff

66
cache/9722736b7350e3b421dd1a82f33a13a5 vendored Normal file
View File

@@ -0,0 +1,66 @@
/* MEDIA: print */
html, body, #main {
font-family: serif;
}
html, body, #main, .works-show, dl.meta, .meta, .meta dd, .meta ul, .preface blockquote, .preface p, .blurb dd ul, .blurb h4, .blurb h5, .blurb .summary blockquote {
margin: auto;
padding: 2pt;
min-height: auto;
width: auto;
color: black;
background: transparent;
border: none;
position: static;
}
#main p, #main li, #main dd {
font: normal 11pt serif;
line-height: 1.2;
margin: auto;
text-indent: 22pt;
}
#header, #footer, #dashboard, img, #skiplinks, .navigation, .meta dt, #feedback, .meta .stats, .blurb dt, .filters, .pagination, .flash, input, .landmark {
display: none;
}
.meta dd, .meta ul, .blurb dd, .blurb .stats dt, .blurb dd ul, .blurb ul li, .blurb .datetime {
font-size: 9pt;
display: inline;
}
li.blurb {
border-bottom: 3pt double black;
margin: 6pt auto;
}
a, a:link, a:visited {
color: black;
text-decoration: underline;
border: 0;
}
.meta a, .meta a:link, .meta a:visited, .blurb a:link, .blurb a:visited {
text-decoration: none;
}
.docs .userstuff summary .heading {
display: inline;
}
/* CSS3 perks, print urls after links */
a:after {
content: " (" attr(href) ") ";
font-size: 9pt;
}
a[href^="/"]:after {
content: " (http://agento3.miscs.dev" attr(href) ") ";
}
.meta a:link:after, .meta a:visited:after, .blurb a:link:after, .blurb a:link:visited, .byline a:link:after, .byline a:visited:after {
content: " ";
}

View File

@@ -0,0 +1,8 @@
/*! jquery.livequery - v1.3.6 - 2013-08-26
* Copyright (c)
* (c) 2010, Brandon Aaron (http://brandonaaron.net)
* (c) 2012 - 2013, Alexander Zaytsev (http://hazzik.ru/en)
* Dual licensed under the MIT (MIT_LICENSE.txt)
* and GPL Version 2 (GPL_LICENSE.txt) licenses.
*/
!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof exports?a(require("jquery")):a(jQuery)}(function(a,b){function c(a,b,c,d){return!(a.selector!=b.selector||a.context!=b.context||c&&c.$lqguid!=b.fn.$lqguid||d&&d.$lqguid!=b.fn2.$lqguid)}a.extend(a.fn,{livequery:function(b,e){var f,g=this;return a.each(d.queries,function(a,d){return c(g,d,b,e)?(f=d)&&!1:void 0}),f=f||new d(g.selector,g.context,b,e),f.stopped=!1,f.run(),g},expire:function(b,e){var f=this;return a.each(d.queries,function(a,g){c(f,g,b,e)&&!f.stopped&&d.stop(g.id)}),f}});var d=a.livequery=function(b,c,e,f){var g=this;return g.selector=b,g.context=c,g.fn=e,g.fn2=f,g.elements=a([]),g.stopped=!1,g.id=d.queries.push(g)-1,e.$lqguid=e.$lqguid||d.guid++,f&&(f.$lqguid=f.$lqguid||d.guid++),g};d.prototype={stop:function(){var b=this;b.stopped||(b.fn2&&b.elements.each(b.fn2),b.elements=a([]),b.stopped=!0)},run:function(){var b=this;if(!b.stopped){var c=b.elements,d=a(b.selector,b.context),e=d.not(c),f=c.not(d);b.elements=d,e.each(b.fn),b.fn2&&f.each(b.fn2)}}},a.extend(d,{guid:0,queries:[],queue:[],running:!1,timeout:null,registered:[],checkQueue:function(){if(d.running&&d.queue.length)for(var a=d.queue.length;a--;)d.queries[d.queue.shift()].run()},pause:function(){d.running=!1},play:function(){d.running=!0,d.run()},registerPlugin:function(){a.each(arguments,function(b,c){if(a.fn[c]&&!(a.inArray(c,d.registered)>0)){var e=a.fn[c];a.fn[c]=function(){var a=e.apply(this,arguments);return d.run(),a},d.registered.push(c)}})},run:function(c){c!==b?a.inArray(c,d.queue)<0&&d.queue.push(c):a.each(d.queries,function(b){a.inArray(b,d.queue)<0&&d.queue.push(b)}),d.timeout&&clearTimeout(d.timeout),d.timeout=setTimeout(d.checkQueue,20)},stop:function(c){c!==b?d.queries[c].stop():a.each(d.queries,d.prototype.stop)}}),d.registerPlugin("append","prepend","after","before","wrap","attr","removeAttr","addClass","removeClass","toggleClass","empty","remove","html","prop","removeProp"),a(function(){d.play()})});

924
cache/c7be43ce9c3c20e7bfed57686ab4f46e vendored Normal file
View File

@@ -0,0 +1,924 @@
// LiveValidation 1.3 (standalone version)
// Copyright (c) 2007-2008 Alec Hill (www.livevalidation.com)
// LiveValidation is licensed under the terms of the MIT License
/*********************************************** LiveValidation class ***********************************/
/**
* validates a form field in real-time based on validations you assign to it
*
* @var element {mixed} - either a dom element reference or the string id of the element to validate
* @var optionsObj {Object} - general options, see below for details
*
* optionsObj properties:
* validMessage {String} - the message to show when the field passes validation
* (DEFAULT: "Thankyou!")
* onValid {Function} - function to execute when field passes validation
* (DEFAULT: function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); } )
* onInvalid {Function} - function to execute when field fails validation
* (DEFAULT: function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); })
* insertAfterWhatNode {Int} - position to insert default message
* (DEFAULT: the field that is being validated)
* onlyOnBlur {Boolean} - whether you want it to validate as you type or only on blur
* (DEFAULT: false)
* wait {Integer} - the time you want it to pause from the last keystroke before it validates (ms)
* (DEFAULT: 0)
* onlyOnSubmit {Boolean} - whether should be validated only when the form it belongs to is submitted
* (DEFAULT: false)
*/
var LiveValidation = function(element, optionsObj){
this.initialize(element, optionsObj);
}
LiveValidation.VERSION = '1.3 standalone';
/** element types constants ****/
LiveValidation.TEXTAREA = 1;
LiveValidation.TEXT = 2;
LiveValidation.PASSWORD = 3;
LiveValidation.CHECKBOX = 4;
LiveValidation.SELECT = 5;
LiveValidation.FILE = 6;
/****** Static methods *******/
/**
* pass an array of LiveValidation objects and it will validate all of them
*
* @var validations {Array} - an array of LiveValidation objects
* @return {Bool} - true if all passed validation, false if any fail
*/
LiveValidation.massValidate = function(validations){
var returnValue = true;
for(var i = 0, len = validations.length; i < len; ++i ){
var valid = validations[i].validate();
if(returnValue) returnValue = valid;
}
return returnValue;
}
/****** prototype ******/
LiveValidation.prototype = {
validClass: 'LV_valid',
invalidClass: 'LV_invalid',
messageClass: 'LV_validation_message',
validFieldClass: 'LV_valid_field',
invalidFieldClass: 'LV_invalid_field',
/**
* initialises all of the properties and events
*
* @var - Same as constructor above
*/
initialize: function(element, optionsObj){
var self = this;
if(!element) throw new Error("LiveValidation::initialize - No element reference or element id has been provided!");
this.element = element.nodeName ? element : document.getElementById(element);
if(!this.element) throw new Error("LiveValidation::initialize - No element with reference or id of '" + element + "' exists!");
// default properties that could not be initialised above
this.validations = [];
this.elementType = this.getElementType();
this.form = this.element.form;
// options
var options = optionsObj || {};
this.validMessage = options.validMessage || ''; // AO3
var node = options.insertAfterWhatNode || this.element;
this.insertAfterWhatNode = node.nodeType ? node : document.getElementById(node);
this.onValid = options.onValid || function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); };
this.onInvalid = options.onInvalid || function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); };
this.onlyOnBlur = options.onlyOnBlur || false;
this.wait = options.wait || 0;
this.onlyOnSubmit = options.onlyOnSubmit || false;
// add to form if it has been provided
if(this.form){
this.formObj = LiveValidationForm.getInstance(this.form);
this.formObj.addField(this);
}
// events
// collect old events
this.oldOnFocus = this.element.onfocus || function(){};
this.oldOnBlur = this.element.onblur || function(){};
this.oldOnClick = this.element.onclick || function(){};
this.oldOnChange = this.element.onchange || function(){};
this.oldOnKeyup = this.element.onkeyup || function(){};
this.element.onfocus = function(e){ self.doOnFocus(e); return self.oldOnFocus.call(this, e); }
if(!this.onlyOnSubmit){
switch(this.elementType){
case LiveValidation.CHECKBOX:
this.element.onclick = function(e){ self.validate(); return self.oldOnClick.call(this, e); }
// let it run into the next to add a change event too
case LiveValidation.SELECT:
case LiveValidation.FILE:
this.element.onchange = function(e){ self.validate(); return self.oldOnChange.call(this, e); }
break;
default:
if(!this.onlyOnBlur) this.element.onkeyup = function(e){ self.deferValidation(); return self.oldOnKeyup.call(this, e); }
this.element.onblur = function(e){ self.doOnBlur(e); return self.oldOnBlur.call(this, e); }
}
}
this.validate();
},
/**
* destroys the instance's events (restoring previous ones) and removes it from any LiveValidationForms
*/
destroy: function(){
if(this.formObj){
// remove the field from the LiveValidationForm
this.formObj.removeField(this);
// destroy the LiveValidationForm if no LiveValidation fields left in it
this.formObj.destroy();
}
// remove events - set them back to the previous events
this.element.onfocus = this.oldOnFocus;
if(!this.onlyOnSubmit){
switch(this.elementType){
case LiveValidation.CHECKBOX:
this.element.onclick = this.oldOnClick;
// let it run into the next to add a change event too
case LiveValidation.SELECT:
case LiveValidation.FILE:
this.element.onchange = this.oldOnChange;
break;
default:
if(!this.onlyOnBlur) this.element.onkeyup = this.oldOnKeyup;
this.element.onblur = this.oldOnBlur;
}
}
this.validations = [];
this.removeMessageAndFieldClass();
},
/**
* adds a validation to perform to a LiveValidation object
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Object} - the LiveValidation object itself so that calls can be chained
*/
add: function(validationFunction, validationParamsObj){
this.validations.push( {type: validationFunction, params: validationParamsObj || {} } );
return this;
},
/**
* removes a validation from a LiveValidation object - must have exactly the same arguments as used to add it
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Object} - the LiveValidation object itself so that calls can be chained
*/
remove: function(validationFunction, validationParamsObj){
var found = false;
for( var i = 0, len = this.validations.length; i < len; i++ ){
if( this.validations[i].type == validationFunction ){
if (this.validations[i].params == validationParamsObj) {
found = true;
break;
}
}
}
if(found) this.validations.splice(i,1);
return this;
},
/**
* makes the validation wait the alotted time from the last keystroke
*/
deferValidation: function(e){
if(this.wait >= 300) this.removeMessageAndFieldClass();
var self = this;
if(this.timeout) clearTimeout(self.timeout);
this.timeout = setTimeout( function(){ self.validate() }, self.wait);
},
/**
* // AO3
* sets the focused flag to false when field loses focus and triggers TinyMCE to save content into field
*/
doOnBlur: function(e){
if (typeof(tinyMCE)!="undefined") tinyMCE.triggerSave(); // AO3
this.focused = false;
this.validate(e);
},
/**
* sets the focused flag to true when field gains focus
*/
doOnFocus: function(e){
this.focused = true;
},
/**
* gets the type of element, to check whether it is compatible
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
*/
getElementType: function(){
switch(true){
case (this.element.nodeName.toUpperCase() == 'TEXTAREA'):
return LiveValidation.TEXTAREA;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'TEXT'):
return LiveValidation.TEXT;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'PASSWORD'):
return LiveValidation.PASSWORD;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'CHECKBOX'):
return LiveValidation.CHECKBOX;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'FILE'):
return LiveValidation.FILE;
case (this.element.nodeName.toUpperCase() == 'SELECT'):
return LiveValidation.SELECT;
case (this.element.nodeName.toUpperCase() == 'INPUT'):
throw new Error('LiveValidation::getElementType - Cannot use LiveValidation on an ' + this.element.type + ' input!');
default:
throw new Error('LiveValidation::getElementType - Element must be an input, select, or textarea!');
}
},
/**
* loops through all the validations added to the LiveValidation object and checks them one by one
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Boolean} - whether the all the validations passed or if one failed
*/
doValidations: function(){
this.validationFailed = false;
for(var i = 0, len = this.validations.length; i < len; ++i){
var validation = this.validations[i];
switch(validation.type){
case Validate.Presence:
case Validate.Confirmation:
case Validate.Acceptance:
this.displayMessageWhenEmpty = true;
this.validationFailed = !this.validateElement(validation.type, validation.params);
break;
default:
this.validationFailed = !this.validateElement(validation.type, validation.params);
break;
}
if(this.validationFailed) return false;
}
this.message = this.validMessage;
return true;
},
/**
* performs validation on the element and handles any error (validation or otherwise) it throws up
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Boolean} - whether the validation has passed or failed
*/
validateElement: function(validationFunction, validationParamsObj){
// AO3: we want validations to ignore leading and trailing whitespace, since it will be removed
var originalValue = (this.elementType == LiveValidation.SELECT) ? this.element.options[this.element.selectedIndex].value : this.element.value;
var value = $j.trim(originalValue);
// AO3: we also want newlines to be counted as "\r\n"s, regardless of the OS and browsers' whim;
// AO3: so we count any single "\n"s and "\r"s as "\r\n", which is what they'll end up as in the db anyway
if(typeof(value)=="string"){
value = (value.replace(/\r\n/g,"\n")).replace(/\r|\n/g,"\r\n");
}
// end AO3
if(validationFunction == Validate.Acceptance){
if(this.elementType != LiveValidation.CHECKBOX) throw new Error('LiveValidation::validateElement - Element to validate acceptance must be a checkbox!');
value = this.element.checked;
}
var isValid = true;
try{
validationFunction(value, validationParamsObj);
} catch(error) {
if(error instanceof Validate.Error){
if( value !== '' || (value === '' && this.displayMessageWhenEmpty) ){
this.validationFailed = true;
this.message = error.message;
isValid = false;
}
}else{
throw error;
}
}finally{
return isValid;
}
},
/**
* makes it do the all the validations and fires off the onValid or onInvalid callbacks
*
* @return {Boolean} - whether the all the validations passed or if one failed
*/
validate: function(){
if (this.element.disabled) return true;
var isValid = this.doValidations();
if (isValid) {
this.onValid();
if (typeof jQuery != "undefined") enableSubmit();
return true;
} else {
this.onInvalid();
return false;
}
},
/**
* enables the field
*
* @return {LiveValidation} - the LiveValidation object for chaining
*/
enable: function(){
this.element.disabled = false;
return this;
},
/**
* disables the field and removes any message and styles associated with the field
*
* @return {LiveValidation} - the LiveValidation object for chaining
*/
disable: function(){
this.element.disabled = true;
this.removeMessageAndFieldClass();
return this;
},
/** Message insertion methods ****************************
*
* These are only used in the onValid and onInvalid callback functions and so if you overide the default callbacks,
* you must either impliment your own functions to do whatever you want, or call some of these from them if you
* want to keep some of the functionality
*/
/**
* makes a span containg the passed or failed message
*
* @return {HTMLSpanObject} - a span element with the message in it
*/
createMessageSpan: function(){
var span = document.createElement('span');
var textNode = document.createTextNode(this.message);
span.appendChild(textNode);
span.role = "alert";
span.id = this.element.id + "_" + this.messageClass;
return span;
},
/**
* inserts the element containing the message in place of the element that already exists (if it does)
*
* @var elementToIsert {HTMLElementObject} - an element node to insert
*/
insertMessage: function(elementToInsert){
this.removeMessage();
var className = this.validationFailed ? this.invalidClass : this.validClass;
elementToInsert.className += ' ' + this.messageClass + ' ' + className;
if(this.insertAfterWhatNode.nextSibling){
this.insertAfterWhatNode.parentNode.insertBefore(elementToInsert, this.insertAfterWhatNode.nextSibling);
} else {
this.insertAfterWhatNode.parentNode.appendChild(elementToInsert);
}
},
/**
* changes the class of the field based on whether it is valid or not
*/
addFieldClass: function(){
this.removeFieldClass();
if(!this.validationFailed){
if(this.displayMessageWhenEmpty || this.element.value != ''){
this.element.setAttribute("aria-invalid", false);
this.element.removeAttribute("aria-describedby");
if(this.element.className.indexOf(this.validFieldClass) == -1) this.element.className += ' ' + this.validFieldClass;
}
} else {
this.element.setAttribute("aria-invalid", true);
this.element.setAttribute("aria-describedby", this.element.id + "_" + this.messageClass);
if(this.element.className.indexOf(this.invalidFieldClass) == -1) this.element.className += ' ' + this.invalidFieldClass;
}
},
/**
* removes the message element if it exists, so that the new message will replace it
*/
removeMessage: function(){
var nextEl;
var el = this.insertAfterWhatNode;
while(el.nextSibling){
if(el.nextSibling.nodeType === 1){
nextEl = el.nextSibling;
break;
}
el = el.nextSibling;
}
if(nextEl && nextEl.className.indexOf(this.messageClass) != -1) this.insertAfterWhatNode.parentNode.removeChild(nextEl);
},
/**
* removes the class that has been applied to the field to indicte if valid or not
*/
removeFieldClass: function(){
if(this.element.className.indexOf(this.invalidFieldClass) != -1) this.element.className = this.element.className.split(this.invalidFieldClass).join('');
if(this.element.className.indexOf(this.validFieldClass) != -1) this.element.className = this.element.className.split(this.validFieldClass).join(' ');
},
/**
* removes the message and the field class
*/
removeMessageAndFieldClass: function(){
this.removeMessage();
this.removeFieldClass();
}
} // end of LiveValidation class
/*************************************** LiveValidationForm class ****************************************/
/**
* This class is used internally by LiveValidation class to associate a LiveValidation field with a form it is icontained in one
*
* It will therefore not really ever be needed to be used directly by the developer, unless they want to associate a LiveValidation
* field with a form that it is not a child of
*/
/**
* handles validation of LiveValidation fields belonging to this form on its submittal
*
* @var element {HTMLFormElement} - a dom element reference to the form to turn into a LiveValidationForm
*/
var LiveValidationForm = function(element){
this.initialize(element);
}
/**
* namespace to hold instances
*/
LiveValidationForm.instances = {};
/**
* gets the instance of the LiveValidationForm if it has already been made or creates it if it doesnt exist
*
* @var element {HTMLFormElement} - a dom element reference to a form
*/
LiveValidationForm.getInstance = function(element){
var rand = Math.random() * Math.random();
if(!element.id) element.id = 'formId_' + rand.toString().replace(/\./, '') + new Date().valueOf();
if(!LiveValidationForm.instances[element.id]) LiveValidationForm.instances[element.id] = new LiveValidationForm(element);
return LiveValidationForm.instances[element.id];
}
LiveValidationForm.prototype = {
/**
* constructor for LiveValidationForm - handles validation of LiveValidation fields belonging to this form on its submittal
*
* @var element {HTMLFormElement} - a dom element reference to the form to turn into a LiveValidationForm
*/
initialize: function(element){
this.name = element.id;
this.element = element;
this.fields = [];
// preserve the old onsubmit event
// AO3: tinyMCE save needs to be triggered here so live validation recognises content in the rich text editor
this.oldOnSubmit = this.element.onsubmit || function(){};
var self = this;
this.element.onsubmit = function(e){
if (typeof(tinyMCE)!="undefined") tinyMCE.triggerSave(this.fields); // AO3
var ret = (LiveValidation.massValidate(self.fields)) ? self.oldOnSubmit.call(this, e || window.event) !== false : false;
// AO3: don't freeze the form if the user has clicked on the 'cancel' button -elz, 3/2/09, Enigel 3/7/11
var buttonClicked = document.activeElement || this.explicitOriginalTarget;
if (buttonClicked.name == 'cancel_button') ret = true;
else if (!ret) {
scrollToErrorIfFound();
enableSubmit();
}
return ret;
}
},
/**
* adds a LiveValidation field to the forms fields array
*
* @var element {LiveValidation} - a LiveValidation object
*/
addField: function(newField){
this.fields.push(newField);
},
/**
* removes a LiveValidation field from the forms fields array
*
* @var victim {LiveValidation} - a LiveValidation object
*/
removeField: function(victim){
var victimless = [];
for( var i = 0, len = this.fields.length; i < len; i++){
if(this.fields[i] !== victim) victimless.push(this.fields[i]);
}
this.fields = victimless;
},
/**
* destroy this instance and its events
*
* @var force {Boolean} - whether to force the detruction even if there are fields still associated
*/
destroy: function(force){
// only destroy if has no fields and not being forced
if (this.fields.length != 0 && !force) return false;
// remove events - set back to previous events
this.element.onsubmit = this.oldOnSubmit;
// remove from the instances namespace
LiveValidationForm.instances[this.name] = null;
return true;
}
}// end of LiveValidationForm prototype
/*************************************** Validate class ****************************************/
/**
* This class contains all the methods needed for doing the actual validation itself
*
* All methods are static so that they can be used outside the context of a form field
* as they could be useful for validating stuff anywhere you want really
*
* All of them will return true if the validation is successful, but will raise a ValidationError if
* they fail, so that this can be caught and the message explaining the error can be accessed ( as just
* returning false would leave you a bit in the dark as to why it failed )
*
* Can use validation methods alone and wrap in a try..catch statement yourself if you want to access the failure
* message and handle the error, or use the Validate::now method if you just want true or false
*/
var Validate = {
/**
* validates that the field has been filled in
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Can't be empty!")
*/
Presence: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Can't be empty!";
if(value === '' || value === null || value === undefined){
Validate.fail(message);
}
return true;
},
/**
* validates that the value is numeric, does not fall within a given range of numbers
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* notANumberMessage {String} - the message to show when the validation fails when value is not a number
* (DEFAULT: "Must be a number!")
* notAnIntegerMessage {String} - the message to show when the validation fails when value is not an integer
* (DEFAULT: "Must be a number!")
* wrongNumberMessage {String} - the message to show when the validation fails when is param is used
* (DEFAULT: "Must be {is}!")
* tooLowMessage {String} - the message to show when the validation fails when minimum param is used
* (DEFAULT: "Must not be less than {minimum}!")
* tooHighMessage {String} - the message to show when the validation fails when maximum param is used
* (DEFAULT: "Must not be more than {maximum}!")
* is {Int} - the length must be this long
* minimum {Int} - the minimum length allowed
* maximum {Int} - the maximum length allowed
* onlyInteger {Boolean} - if true will only allow integers to be valid
* (DEFAULT: false)
*
* NB. can be checked if it is within a range by specifying both a minimum and a maximum
* NB. will evaluate numbers represented in scientific form (ie 2e10) correctly as numbers
*/
Numericality: function(value, paramsObj){
var suppliedValue = value;
var value = Number(value);
var paramsObj = paramsObj || {};
var minimum = ((paramsObj.minimum) || (paramsObj.minimum == 0)) ? paramsObj.minimum : null;;
var maximum = ((paramsObj.maximum) || (paramsObj.maximum == 0)) ? paramsObj.maximum : null;
var is = ((paramsObj.is) || (paramsObj.is == 0)) ? paramsObj.is : null;
var notANumberMessage = paramsObj.notANumberMessage || "Must be a number!";
var notAnIntegerMessage = paramsObj.notAnIntegerMessage || "Must be an integer!";
var wrongNumberMessage = paramsObj.wrongNumberMessage || "Must be " + is + "!";
var tooLowMessage = paramsObj.tooLowMessage || "Must not be less than " + minimum + "!";
var tooHighMessage = paramsObj.tooHighMessage || "Must not be more than " + maximum + "!";
if (!isFinite(value)) Validate.fail(notANumberMessage);
if (paramsObj.onlyInteger && (/\.0+$|\.$/.test(String(suppliedValue)) || value != parseInt(value)) ) Validate.fail(notAnIntegerMessage);
switch(true){
case (is !== null):
if( value != Number(is) ) Validate.fail(wrongNumberMessage);
break;
case (minimum !== null && maximum !== null):
Validate.Numericality(value, {tooLowMessage: tooLowMessage, minimum: minimum});
Validate.Numericality(value, {tooHighMessage: tooHighMessage, maximum: maximum});
break;
case (minimum !== null):
if( value < Number(minimum) ) Validate.fail(tooLowMessage);
break;
case (maximum !== null):
if( value > Number(maximum) ) Validate.fail(tooHighMessage);
break;
}
return true;
},
/**
* validates against a RegExp pattern
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Not valid!")
* pattern {RegExp} - the regular expression pattern
* (DEFAULT: /./)
* negate {Boolean} - if set to true, will validate true if the pattern is not matched
* (DEFAULT: false)
*
* NB. will return true for an empty string, to allow for non-required, empty fields to validate.
* If you do not want this to be the case then you must either add a LiveValidation.PRESENCE validation
* or build it into the regular expression pattern
*/
Format: function(value, paramsObj){
var value = String(value);
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Not valid!";
var pattern = paramsObj.pattern || /./;
var negate = paramsObj.negate || false;
if(!negate && !pattern.test(value)) Validate.fail(message); // normal
if(negate && pattern.test(value)) Validate.fail(message); // negated
return true;
},
/**
* validates that the field contains a valid email address
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must be a number!" or "Must be an integer!")
*/
Email: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Must be a valid email address!";
Validate.Format(value, { failureMessage: message, pattern: /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i } );
return true;
},
/**
* validates the length of the value
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* wrongLengthMessage {String} - the message to show when the fails when is param is used
* (DEFAULT: "Must be {is} characters long!")
* tooShortMessage {String} - the message to show when the fails when minimum param is used
* (DEFAULT: "Must not be less than {minimum} characters long!")
* tooLongMessage {String} - the message to show when the fails when maximum param is used
* (DEFAULT: "Must not be more than {maximum} characters long!")
* is {Int} - the length must be this long
* minimum {Int} - the minimum length allowed
* maximum {Int} - the maximum length allowed
*
* NB. can be checked if it is within a range by specifying both a minimum and a maximum
*/
Length: function(value, paramsObj){
var value = String(value);
var paramsObj = paramsObj || {};
var minimum = ((paramsObj.minimum) || (paramsObj.minimum == 0)) ? paramsObj.minimum : null;
var maximum = ((paramsObj.maximum) || (paramsObj.maximum == 0)) ? paramsObj.maximum : null;
var is = ((paramsObj.is) || (paramsObj.is == 0)) ? paramsObj.is : null;
var wrongLengthMessage = paramsObj.wrongLengthMessage || "Must be " + is + " characters long!";
var tooShortMessage = paramsObj.tooShortMessage || "Must not be less than " + minimum + " characters long!";
var tooLongMessage = paramsObj.tooLongMessage || "Must not be more than " + maximum + " characters long!";
switch(true){
case (is !== null):
if( value.length != Number(is) ) Validate.fail(wrongLengthMessage);
break;
case (minimum !== null && maximum !== null):
Validate.Length(value, {tooShortMessage: tooShortMessage, minimum: minimum});
Validate.Length(value, {tooLongMessage: tooLongMessage, maximum: maximum});
break;
case (minimum !== null):
if( value.length < Number(minimum) ) Validate.fail(tooShortMessage);
break;
case (maximum !== null):
if( value.length > Number(maximum) ) Validate.fail(tooLongMessage);
break;
default:
throw new Error("Validate::Length - Length(s) to validate against must be provided!");
}
return true;
},
/**
* validates that the value falls within a given set of values
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must be included in the list!")
* within {Array} - an array of values that the value should fall in
* (DEFAULT: [])
* allowNull {Bool} - if true, and a null value is passed in, validates as true
* (DEFAULT: false)
* partialMatch {Bool} - if true, will not only validate against the whole value to check but also if it is a substring of the value
* (DEFAULT: false)
* caseSensitive {Bool} - if false will compare strings case insensitively
* (DEFAULT: true)
* negate {Bool} - if true, will validate that the value is not within the given set of values
* (DEFAULT: false)
*/
Inclusion: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Must be included in the list!";
var caseSensitive = (paramsObj.caseSensitive === false) ? false : true;
if(paramsObj.allowNull && value == null) return true;
if(!paramsObj.allowNull && value == null) Validate.fail(message);
var within = paramsObj.within || [];
//if case insensitive, make all strings in the array lowercase, and the value too
if(!caseSensitive){
var lowerWithin = [];
for(var j = 0, length = within.length; j < length; ++j){
var item = within[j];
if(typeof item == 'string') item = item.toLowerCase();
lowerWithin.push(item);
}
within = lowerWithin;
if(typeof value == 'string') value = value.toLowerCase();
}
var found = false;
for(var i = 0, length = within.length; i < length; ++i){
if(within[i] == value) found = true;
if(paramsObj.partialMatch){
if(value.indexOf(within[i]) != -1) found = true;
}
}
if( (!paramsObj.negate && !found) || (paramsObj.negate && found) ) Validate.fail(message);
return true;
},
/**
* validates that the value does not fall within a given set of values
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must not be included in the list!")
* within {Array} - an array of values that the value should not fall in
* (DEFAULT: [])
* allowNull {Bool} - if true, and a null value is passed in, validates as true
* (DEFAULT: false)
* partialMatch {Bool} - if true, will not only validate against the whole value to check but also if it is a substring of the value
* (DEFAULT: false)
* caseSensitive {Bool} - if false will compare strings case insensitively
* (DEFAULT: true)
*/
Exclusion: function(value, paramsObj){
var paramsObj = paramsObj || {};
paramsObj.failureMessage = paramsObj.failureMessage || "Must not be included in the list!";
paramsObj.negate = true;
Validate.Inclusion(value, paramsObj);
return true;
},
/**
* validates that the value matches that in another field
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Does not match!")
* match {String} - id of the field that this one should match
*/
Confirmation: function(value, paramsObj){
if(!paramsObj.match) throw new Error("Validate::Confirmation - Error validating confirmation: Id of element to match must be provided!");
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Does not match!";
var match = paramsObj.match.nodeName ? paramsObj.match : document.getElementById(paramsObj.match);
if(!match) throw new Error("Validate::Confirmation - There is no reference with name of, or element with id of '" + paramsObj.match + "'!");
if(value != match.value){
Validate.fail(message);
}
return true;
},
/**
* validates that the value is true (for use primarily in detemining if a checkbox has been checked)
*
* @var value {mixed} - value to be checked if true or not (usually a boolean from the checked value of a checkbox)
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must be accepted!")
*/
Acceptance: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Must be accepted!";
if(!value){
Validate.fail(message);
}
return true;
},
/**
* validates against a custom function that returns true or false (or throws a Validate.Error) when passed the value
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Not valid!")
* against {Function} - a function that will take the value and object of arguments and return true or false
* (DEFAULT: function(){ return true; })
* args {Object} - an object of named arguments that will be passed to the custom function so are accessible through this object within it
* (DEFAULT: {})
*/
Custom: function(value, paramsObj){
var paramsObj = paramsObj || {};
var against = paramsObj.against || function(){ return true; };
var args = paramsObj.args || {};
var message = paramsObj.failureMessage || "Not valid!";
if(!against(value, args)) Validate.fail(message);
return true;
},
/**
* validates whatever it is you pass in, and handles the validation error for you so it gives a nice true or false reply
*
* @var validationFunction {Function} - validation function to be used (ie Validation.validatePresence )
* @var value {mixed} - value to be checked if true or not (usually a boolean from the checked value of a checkbox)
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
*/
now: function(validationFunction, value, validationParamsObj){
if(!validationFunction) throw new Error("Validate::now - Validation function must be provided!");
var isValid = true;
try{
validationFunction(value, validationParamsObj || {});
} catch(error) {
if(error instanceof Validate.Error){
isValid = false;
}else{
throw error;
}
}finally{
return isValid
}
},
/**
* shortcut for failing throwing a validation error
*
* @var errorMessage {String} - message to display
*/
fail: function(errorMessage){
throw new Validate.Error(errorMessage);
},
Error: function(errorMessage){
this.message = errorMessage;
this.name = 'ValidationError';
}
}
function scrollToErrorIfFound() {
var errorField = $j(".LV_invalid_field").first();
if (errorField.length !== 0) {
$j("html, body").animate({
scrollTop: errorField.offset().top
}, 1000);
errorField.focus();
}
}
// Enable submit button if there are no errors
function enableSubmit() {
if ($j(".LV_invalid_field").first().length === 0) {
$j.rails.enableFormElement($j("input[data-disable-with]"));
}
}

294
cache/d1b5fde27293b4c7d5e5b78261172c48 vendored Normal file
View File

@@ -0,0 +1,294 @@
/* MEDIA: only screen and (max-width: 42em), handheld ENDMEDIA */
#outer {
background: #fff;
font-size: 0.875em;
position: relative;
}
h1, h2, h3 {
word-break: break-all;
/* not supported in all browsers, so we need break-all as a fallback */
word-break: break-word;
}
/* non-JavaScript states
.narrow-shown: should be displayed when this stylesheet is in use
*/
body .narrow-shown {
display: block;
}
.actions li.narrow-shown {
display: inline;
}
/* JavaScript states
.javascript .narrow-hidden: should not be displayed when JS is enabled and this stylesheet is in use
*/
.javascript .narrow-hidden {
display: none;
}
/* 03 region header */
#header .logo {
height: 1.75em;
}
#header .dropdown a:focus {
outline: none;
}
#header .primary > li:first-of-type {
margin-left: 0;
}
#header .dropdown a:focus, #header .dropdown .menu a:focus {
background: transparent;
color: #111;
}
#header .open a:focus {
background: #ddd;
}
#header .primary .dropdown a:focus {
color: #fff;
}
#header .primary .open a:focus {
color: #111;
}
#header .user .open a:focus {
color: #900;
}
#header h2.collections {
padding: 1%;
margin: 0;
}
#header #small_login {
margin-left: 45px;
}
#header .dropdown, #greeting .user {
position: static;
}
#header .menu {
width: 100%;
position: absolute;
left: 0;
}
/* 04 region dashboard */
#dashboard, #dashboard.own {
border-bottom-width: 7px;
border-top-width: 7px;
padding: 0.25em 0;
}
/* 05 region main */
#main, #main.dashboard {
position: static;
}
#main.errors {
background-position: center;
}
#main.session {
background-image: none;
}
#main.errors p, #main.errors .heading {
margin-right: 0;
}
#main.errors p:last-child {
margin-bottom: 500px;
}
/* once we remove the meta class from the work form, we can remove the form .meta selectors here */
.filtered .index, form.filters, form dd, form dt, form .meta dd, form .meta dt, form.inbox {
width: 100%;
max-width: 100%;
min-width: 0;
float: none;
}
.dashboard .index {
float: none;
clear: both
}
.dashboard .landmark {
clear: both;
}
/* 10 types and groups */
.blurb dl.tags dt, .blurb dl.tags dd, dl.meta dt, dl.meta dd, .alphabet .listbox li, .media .listbox {
width: auto;
float: none;
}
.blurb dl.tags dd, dl.meta dd {
margin-left: 1em;
}
.alphabet .listbox li {
display: block;
}
/* 11 group: listbox */
.listbox .index {
width: auto;
}
/* 15 group: comments */
.thread .thread {
margin-left: 1em;
}
.comment .userstuff {
min-height: 0;
}
.comment .icon {
height: 55px;
margin-bottom: 0;
width: 55px;
}
.comment .icon .anonymous {
background: url(/images/imageset.png) no-repeat -75px -395px;
}
.comment .icon .visitor {
background: url(/images/imageset.png) no-repeat -130px -395px;
}
.comment h4.byline {
padding-left: 62px;
}
/* 16 zone: system */
.splash {
padding: 0;
}
.splash div.module, .logged-in .splash div.module {
clear: both;
margin-left: 0;
margin-right: 0;
width: 100%;
}
.splash .intro {
padding-top: 0;
}
.splash .intro h2 {
font-size: 1.5em;
word-break: normal;
}
.session #signin {
margin-left: 0;
width: 100%;
}
/* 18 zone: search browse */
form.filters dl {
width: auto;
}
/* Filters with JavaScript */
.javascript {
background: #ddd;
}
.javascript form.filters {
margin: 0;
max-width: 95%;
position: absolute;
top: 0;
right: -16em;
width: 16em; /* 14em/0.875em */
z-index: 400;
}
.javascript .filters fieldset {
border: none;
margin: 0;
position: relative;
z-index: 450;
box-shadow: none;
}
.javascript .filters p.narrow-shown {
position: relative;
}
.filtering {
right: 14em;
}
.filtering .filters #leave_filters {
background: transparent none;
border-bottom: none;
position: fixed;
top: -101em;
bottom: -101em;
left: -10em;
right: -10em;
z-index: 0;
}
.filtering #leave_filters:focus {
outline: none;
}
/* 21 userstuff */
#workskin {
margin: auto;
}
/* 22 system: messages */
.announcement .userstuff {
margin: 1%;
}
.announcement p.submit {
bottom: -0.5em;
right: 1%;
}
.announcement .thermometer-content {
width: 80%;
}
.announcement .goal .amount {
display: none;
}
.announcement .thermometer .progress .amount {
left: 0;
right: auto;
}

15
cache/e8d39ac2b8645f201df2197c4f56db7f vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* jQuery Shuffle (http://mktgdept.com/jquery-shuffle)
* A jQuery plugin for shuffling a set of elements
*
* v0.0.1 - 13 November 2009
*
* Copyright (c) 2009 Chad Smith (http://twitter.com/chadsmith)
* Dual licensed under the MIT and GPL licenses.
* http://www.opensource.org/licenses/mit-license.php
* http://www.opensource.org/licenses/gpl-license.php
*
* Shuffle elements using: $(selector).shuffle() or $.shuffle(selector)
*
**/
(function(d){d.fn.shuffle=function(c){c=[];return this.each(function(){c.push(d(this).clone(true))}).each(function(a,b){d(b).replaceWith(c[a=Math.floor(Math.random()*c.length)]);c.splice(a,1)})};d.shuffle=function(a){return d(a).shuffle()}})(jQuery);

File diff suppressed because one or more lines are too long

BIN
cache/e9d6dffb2eb319ae85deae26872daf4a vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

534
cache/f688920d715a669db37b313dd3ecffd6 vendored Normal file
View File

@@ -0,0 +1,534 @@
(function($, undefined) {
/**
* Unobtrusive scripting adapter for jQuery
* https://github.com/rails/jquery-ujs
*
* Requires jQuery 1.8.0 or later.
*
* Released under the MIT license
*
*/
// Cut down on the number of issues from people inadvertently including jquery_ujs twice
// by detecting and raising an error when it happens.
'use strict';
if ( $.rails !== undefined ) {
$.error('jquery-ujs has already been loaded!');
}
// Shorthand to make it a little easier to call public rails functions from within rails.js
var rails;
var $document = $(document);
$.rails = rails = {
// Link elements bound by jquery-ujs
linkClickSelector: 'a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]',
// Button elements bound by jquery-ujs
buttonClickSelector: 'button[data-remote]:not([form]):not(form button), button[data-confirm]:not([form]):not(form button)',
// Select elements bound by jquery-ujs
inputChangeSelector: 'select[data-remote], input[data-remote], textarea[data-remote]',
// Form elements bound by jquery-ujs
formSubmitSelector: 'form',
// Form input elements bound by jquery-ujs
formInputClickSelector: 'form input[type=submit], form input[type=image], form button[type=submit], form button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])',
// Form input elements disabled during form submission
disableSelector: 'input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled',
// Form input elements re-enabled after form submission
enableSelector: 'input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled',
// Form required input elements
requiredInputSelector: 'input[name][required]:not([disabled]), textarea[name][required]:not([disabled])',
// Form file input elements
fileInputSelector: 'input[type=file]:not([disabled])',
// Link onClick disable selector with possible reenable after remote submission
linkDisableSelector: 'a[data-disable-with], a[data-disable]',
// Button onClick disable selector with possible reenable after remote submission
buttonDisableSelector: 'button[data-remote][data-disable-with], button[data-remote][data-disable]',
// Up-to-date Cross-Site Request Forgery token
csrfToken: function() {
return $('meta[name=csrf-token]').attr('content');
},
// URL param that must contain the CSRF token
csrfParam: function() {
return $('meta[name=csrf-param]').attr('content');
},
// Make sure that every Ajax request sends the CSRF token
CSRFProtection: function(xhr) {
var token = rails.csrfToken();
if (token) xhr.setRequestHeader('X-CSRF-Token', token);
},
// Make sure that all forms have actual up-to-date tokens (cached forms contain old ones)
refreshCSRFTokens: function(){
$('form input[name="' + rails.csrfParam() + '"]').val(rails.csrfToken());
},
// Triggers an event on an element and returns false if the event result is false
fire: function(obj, name, data) {
var event = $.Event(name);
obj.trigger(event, data);
return event.result !== false;
},
// Default confirm dialog, may be overridden with custom confirm dialog in $.rails.confirm
confirm: function(message) {
return confirm(message);
},
// Default ajax function, may be overridden with custom function in $.rails.ajax
ajax: function(options) {
return $.ajax(options);
},
// Default way to get an element's href. May be overridden at $.rails.href.
href: function(element) {
return element[0].href;
},
// Checks "data-remote" if true to handle the request through a XHR request.
isRemote: function(element) {
return element.data('remote') !== undefined && element.data('remote') !== false;
},
// Submits "remote" forms and links with ajax
handleRemote: function(element) {
var method, url, data, withCredentials, dataType, options;
if (rails.fire(element, 'ajax:before')) {
withCredentials = element.data('with-credentials') || null;
dataType = element.data('type') || ($.ajaxSettings && $.ajaxSettings.dataType);
if (element.is('form')) {
method = element.data('ujs:submit-button-formmethod') || element.attr('method');
url = element.data('ujs:submit-button-formaction') || element.attr('action');
data = $(element[0].elements).serializeArray();
// memoized value from clicked submit button
var button = element.data('ujs:submit-button');
if (button) {
data.push(button);
element.data('ujs:submit-button', null);
}
element.data('ujs:submit-button-formmethod', null);
element.data('ujs:submit-button-formaction', null);
} else if (element.is(rails.inputChangeSelector)) {
method = element.data('method');
url = element.data('url');
data = element.serialize();
if (element.data('params')) data = data + '&' + element.data('params');
} else if (element.is(rails.buttonClickSelector)) {
method = element.data('method') || 'get';
url = element.data('url');
data = element.serialize();
if (element.data('params')) data = data + '&' + element.data('params');
} else {
method = element.data('method');
url = rails.href(element);
data = element.data('params') || null;
}
options = {
type: method || 'GET', data: data, dataType: dataType,
// stopping the "ajax:beforeSend" event will cancel the ajax request
beforeSend: function(xhr, settings) {
if (settings.dataType === undefined) {
xhr.setRequestHeader('accept', '*/*;q=0.5, ' + settings.accepts.script);
}
if (rails.fire(element, 'ajax:beforeSend', [xhr, settings])) {
element.trigger('ajax:send', xhr);
} else {
return false;
}
},
success: function(data, status, xhr) {
element.trigger('ajax:success', [data, status, xhr]);
},
complete: function(xhr, status) {
element.trigger('ajax:complete', [xhr, status]);
},
error: function(xhr, status, error) {
element.trigger('ajax:error', [xhr, status, error]);
},
crossDomain: rails.isCrossDomain(url)
};
// There is no withCredentials for IE6-8 when
// "Enable native XMLHTTP support" is disabled
if (withCredentials) {
options.xhrFields = {
withCredentials: withCredentials
};
}
// Only pass url to `ajax` options if not blank
if (url) { options.url = url; }
return rails.ajax(options);
} else {
return false;
}
},
// Determines if the request is a cross domain request.
isCrossDomain: function(url) {
var originAnchor = document.createElement('a');
originAnchor.href = location.href;
var urlAnchor = document.createElement('a');
try {
urlAnchor.href = url;
// This is a workaround to a IE bug.
urlAnchor.href = urlAnchor.href;
// If URL protocol is false or is a string containing a single colon
// *and* host are false, assume it is not a cross-domain request
// (should only be the case for IE7 and IE compatibility mode).
// Otherwise, evaluate protocol and host of the URL against the origin
// protocol and host.
return !(((!urlAnchor.protocol || urlAnchor.protocol === ':') && !urlAnchor.host) ||
(originAnchor.protocol + '//' + originAnchor.host ===
urlAnchor.protocol + '//' + urlAnchor.host));
} catch (e) {
// If there is an error parsing the URL, assume it is crossDomain.
return true;
}
},
// Handles "data-method" on links such as:
// <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a>
handleMethod: function(link) {
var href = rails.href(link),
method = link.data('method'),
target = link.attr('target'),
csrfToken = rails.csrfToken(),
csrfParam = rails.csrfParam(),
form = $('<form method="post" action="' + href + '"></form>'),
metadataInput = '<input name="_method" value="' + method + '" type="hidden" />';
if (csrfParam !== undefined && csrfToken !== undefined && !rails.isCrossDomain(href)) {
metadataInput += '<input name="' + csrfParam + '" value="' + csrfToken + '" type="hidden" />';
}
if (target) { form.attr('target', target); }
form.hide().append(metadataInput).appendTo('body');
form.submit();
},
// Helper function that returns form elements that match the specified CSS selector
// If form is actually a "form" element this will return associated elements outside the from that have
// the html form attribute set
formElements: function(form, selector) {
return form.is('form') ? $(form[0].elements).filter(selector) : form.find(selector);
},
/* Disables form elements:
- Caches element value in 'ujs:enable-with' data store
- Replaces element text with value of 'data-disable-with' attribute
- Sets disabled property to true
*/
disableFormElements: function(form) {
rails.formElements(form, rails.disableSelector).each(function() {
rails.disableFormElement($(this));
});
},
disableFormElement: function(element) {
var method, replacement;
method = element.is('button') ? 'html' : 'val';
replacement = element.data('disable-with');
if (replacement !== undefined) {
element.data('ujs:enable-with', element[method]());
element[method](replacement);
}
element.prop('disabled', true);
element.data('ujs:disabled', true);
},
/* Re-enables disabled form elements:
- Replaces element text with cached value from 'ujs:enable-with' data store (created in `disableFormElements`)
- Sets disabled property to false
*/
enableFormElements: function(form) {
rails.formElements(form, rails.enableSelector).each(function() {
rails.enableFormElement($(this));
});
},
enableFormElement: function(element) {
var method = element.is('button') ? 'html' : 'val';
if (element.data('ujs:enable-with') !== undefined) {
element[method](element.data('ujs:enable-with'));
element.removeData('ujs:enable-with'); // clean up cache
}
element.prop('disabled', false);
element.removeData('ujs:disabled');
},
/* For 'data-confirm' attribute:
- Fires `confirm` event
- Shows the confirmation dialog
- Fires the `confirm:complete` event
Returns `true` if no function stops the chain and user chose yes; `false` otherwise.
Attaching a handler to the element's `confirm` event that returns a `falsy` value cancels the confirmation dialog.
Attaching a handler to the element's `confirm:complete` event that returns a `falsy` value makes this function
return false. The `confirm:complete` event is fired whether or not the user answered true or false to the dialog.
*/
allowAction: function(element) {
var message = element.data('confirm'),
answer = false, callback;
if (!message) { return true; }
if (rails.fire(element, 'confirm')) {
try {
answer = rails.confirm(message);
} catch (e) {
(console.error || console.log).call(console, e.stack || e);
}
callback = rails.fire(element, 'confirm:complete', [answer]);
}
return answer && callback;
},
// Helper function which checks for blank inputs in a form that match the specified CSS selector
blankInputs: function(form, specifiedSelector, nonBlank) {
var inputs = $(), input, valueToCheck,
selector = specifiedSelector || 'input,textarea',
allInputs = form.find(selector);
allInputs.each(function() {
input = $(this);
valueToCheck = input.is('input[type=checkbox],input[type=radio]') ? input.is(':checked') : !!input.val();
if (valueToCheck === nonBlank) {
// Don't count unchecked required radio if other radio with same name is checked
if (input.is('input[type=radio]') && allInputs.filter('input[type=radio]:checked[name="' + input.attr('name') + '"]').length) {
return true; // Skip to next input
}
inputs = inputs.add(input);
}
});
return inputs.length ? inputs : false;
},
// Helper function which checks for non-blank inputs in a form that match the specified CSS selector
nonBlankInputs: function(form, specifiedSelector) {
return rails.blankInputs(form, specifiedSelector, true); // true specifies nonBlank
},
// Helper function, needed to provide consistent behavior in IE
stopEverything: function(e) {
$(e.target).trigger('ujs:everythingStopped');
e.stopImmediatePropagation();
return false;
},
// Replace element's html with the 'data-disable-with' after storing original html
// and prevent clicking on it
disableElement: function(element) {
var replacement = element.data('disable-with');
if (replacement !== undefined) {
element.data('ujs:enable-with', element.html()); // store enabled state
element.html(replacement);
}
element.bind('click.railsDisable', function(e) { // prevent further clicking
return rails.stopEverything(e);
});
element.data('ujs:disabled', true);
},
// Restore element to its original state which was disabled by 'disableElement' above
enableElement: function(element) {
if (element.data('ujs:enable-with') !== undefined) {
element.html(element.data('ujs:enable-with')); // set to old enabled state
element.removeData('ujs:enable-with'); // clean up cache
}
element.unbind('click.railsDisable'); // enable element
element.removeData('ujs:disabled');
}
};
if (rails.fire($document, 'rails:attachBindings')) {
$.ajaxPrefilter(function(options, originalOptions, xhr){ if ( !options.crossDomain ) { rails.CSRFProtection(xhr); }});
// This event works the same as the load event, except that it fires every
// time the page is loaded.
//
// See https://github.com/rails/jquery-ujs/issues/357
// See https://developer.mozilla.org/en-US/docs/Using_Firefox_1.5_caching
$(window).on('pageshow.rails', function () {
$($.rails.enableSelector).each(function () {
var element = $(this);
if (element.data('ujs:disabled')) {
$.rails.enableFormElement(element);
}
});
$($.rails.linkDisableSelector).each(function () {
var element = $(this);
if (element.data('ujs:disabled')) {
$.rails.enableElement(element);
}
});
});
$document.delegate(rails.linkDisableSelector, 'ajax:complete', function() {
rails.enableElement($(this));
});
$document.delegate(rails.buttonDisableSelector, 'ajax:complete', function() {
rails.enableFormElement($(this));
});
$document.delegate(rails.linkClickSelector, 'click.rails', function(e) {
var link = $(this), method = link.data('method'), data = link.data('params'), metaClick = e.metaKey || e.ctrlKey;
if (!rails.allowAction(link)) return rails.stopEverything(e);
if (!metaClick && link.is(rails.linkDisableSelector)) rails.disableElement(link);
if (rails.isRemote(link)) {
if (metaClick && (!method || method === 'GET') && !data) { return true; }
var handleRemote = rails.handleRemote(link);
// Response from rails.handleRemote() will either be false or a deferred object promise.
if (handleRemote === false) {
rails.enableElement(link);
} else {
handleRemote.fail( function() { rails.enableElement(link); } );
}
return false;
} else if (method) {
rails.handleMethod(link);
return false;
}
});
$document.delegate(rails.buttonClickSelector, 'click.rails', function(e) {
var button = $(this);
if (!rails.allowAction(button) || !rails.isRemote(button)) return rails.stopEverything(e);
if (button.is(rails.buttonDisableSelector)) rails.disableFormElement(button);
var handleRemote = rails.handleRemote(button);
// Response from rails.handleRemote() will either be false or a deferred object promise.
if (handleRemote === false) {
rails.enableFormElement(button);
} else {
handleRemote.fail( function() { rails.enableFormElement(button); } );
}
return false;
});
$document.delegate(rails.inputChangeSelector, 'change.rails', function(e) {
var link = $(this);
if (!rails.allowAction(link) || !rails.isRemote(link)) return rails.stopEverything(e);
rails.handleRemote(link);
return false;
});
$document.delegate(rails.formSubmitSelector, 'submit.rails', function(e) {
var form = $(this),
remote = rails.isRemote(form),
blankRequiredInputs,
nonBlankFileInputs;
if (!rails.allowAction(form)) return rails.stopEverything(e);
// Skip other logic when required values are missing or file upload is present
if (form.attr('novalidate') === undefined) {
if (form.data('ujs:formnovalidate-button') === undefined) {
blankRequiredInputs = rails.blankInputs(form, rails.requiredInputSelector, false);
if (blankRequiredInputs && rails.fire(form, 'ajax:aborted:required', [blankRequiredInputs])) {
return rails.stopEverything(e);
}
} else {
// Clear the formnovalidate in case the next button click is not on a formnovalidate button
// Not strictly necessary to do here, since it is also reset on each button click, but just to be certain
form.data('ujs:formnovalidate-button', undefined);
}
}
if (remote) {
nonBlankFileInputs = rails.nonBlankInputs(form, rails.fileInputSelector);
if (nonBlankFileInputs) {
// Slight timeout so that the submit button gets properly serialized
// (make it easy for event handler to serialize form without disabled values)
setTimeout(function(){ rails.disableFormElements(form); }, 13);
var aborted = rails.fire(form, 'ajax:aborted:file', [nonBlankFileInputs]);
// Re-enable form elements if event bindings return false (canceling normal form submission)
if (!aborted) { setTimeout(function(){ rails.enableFormElements(form); }, 13); }
return aborted;
}
rails.handleRemote(form);
return false;
} else {
// Slight timeout so that the submit button gets properly serialized
setTimeout(function(){ rails.disableFormElements(form); }, 13);
}
});
$document.delegate(rails.formInputClickSelector, 'click.rails', function(event) {
var button = $(this);
if (!rails.allowAction(button)) return rails.stopEverything(event);
// Register the pressed submit button
var name = button.attr('name'),
data = name ? {name:name, value:button.val()} : null;
var form = button.closest('form');
if (form.length === 0) {
form = $('#' + button.attr('form'));
}
form.data('ujs:submit-button', data);
// Save attributes from button
form.data('ujs:formnovalidate-button', button.attr('formnovalidate'));
form.data('ujs:submit-button-formaction', button.attr('formaction'));
form.data('ujs:submit-button-formmethod', button.attr('formmethod'));
});
$document.delegate(rails.formSubmitSelector, 'ajax:send.rails', function(event) {
if (this === event.target) rails.disableFormElements($(this));
});
$document.delegate(rails.formSubmitSelector, 'ajax:complete.rails', function(event) {
if (this === event.target) rails.enableFormElements($(this));
});
$(function(){
rails.refreshCSRFTokens();
});
}
})( jQuery );

186
cache/fd994d17b56e171552534f14fc2cd24b vendored Normal file
View File

@@ -0,0 +1,186 @@
/* ============================================================
* bootstrap-dropdown.js v2.3.1
* http://twitter.github.com/bootstrap/javascript.html#dropdowns
* ============================================================
* Copyright 2012 Twitter, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ============================================================
* OTWARCHIVE DEVS:
*
* When updating to the newest version, make sure to include the
* customizations from LINES 62-70, 103, AND 177-184 and UPDATE THIS
* MESSAGE with the new line numbers. These lines ensure the code works
* without the ARIA menu role and ensure proper behavior when both JS and
* CSS hover are used for menus.
* ========================================================== */
!function ($) {
"use strict"; // jshint ;_;
/* DROPDOWN CLASS DEFINITION
* ========================= */
var toggle = '[data-toggle=dropdown]'
, Dropdown = function (element) {
var $el = $(element).on('click.dropdown.data-api', this.toggle)
$('html').on('click.dropdown.data-api', function () {
$el.parent().removeClass('open')
})
}
Dropdown.prototype = {
constructor: Dropdown
, toggle: function (e) {
var $this = $(this)
, $parent
, isActive
if ($this.is('.disabled, :disabled')) return
$parent = getParent($this)
isActive = $parent.hasClass('open')
clearMenus()
if (isActive) {
$parent.children('ul').hide()
$this.blur()
} else {
$parent
.toggleClass('open')
.children('ul').removeAttr('style')
$this.focus()
}
$this.focus()
return false
}
, keydown: function (e) {
var $this
, $items
, $active
, $parent
, isActive
, index
if (!/(38|40|27)/.test(e.keyCode)) return
$this = $(this)
e.preventDefault()
e.stopPropagation()
if ($this.is('.disabled, :disabled')) return
$parent = getParent($this)
isActive = $parent.hasClass('open')
if (!isActive || (isActive && e.keyCode == 27)) {
if (e.which == 27) $parent.find(toggle).focus()
return $this.click()
}
$items = $('ul.menu li:not(.divider):visible a', $parent)
if (!$items.length) return
index = $items.index($items.filter(':focus'))
if (e.keyCode == 38 && index > 0) index-- // up
if (e.keyCode == 40 && index < $items.length - 1) index++ // down
if (!~index) index = 0
$items
.eq(index)
.focus()
}
}
function clearMenus() {
$(toggle).each(function () {
getParent($(this)).removeClass('open')
})
}
function getParent($this) {
var selector = $this.attr('data-target')
, $parent
if (!selector) {
selector = $this.attr('href')
selector = selector && /#/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7
}
$parent = selector && $(selector)
if (!$parent || !$parent.length) $parent = $this.parent()
return $parent
}
/* DROPDOWN PLUGIN DEFINITION
* ========================== */
var old = $.fn.dropdown
$.fn.dropdown = function (option) {
return this.each(function () {
var $this = $(this)
, data = $this.data('dropdown')
if (!data) $this.data('dropdown', (data = new Dropdown(this)))
if (typeof option == 'string') data[option].call($this)
})
}
$.fn.dropdown.Constructor = Dropdown
/* DROPDOWN NO CONFLICT
* ==================== */
$.fn.dropdown.noConflict = function () {
$.fn.dropdown = old
return this
}
/* APPLY TO STANDARD DROPDOWN ELEMENTS
* =================================== */
$(document)
.on('click.dropdown.data-api', clearMenus)
.on('click.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() })
.on('click.dropdown-menu', function (e) { e.stopPropagation() })
.on('click.dropdown.data-api' , toggle, Dropdown.prototype.toggle)
.on('keydown.dropdown.data-api', toggle + ', ul.menu' , Dropdown.prototype.keydown)
.on('mouseenter', '.dropdown', function (e) {
var $parent = $(this)
if ($parent.siblings('.open').length) {
$parent.children('ul').hide()
}
})
.on('mouseleave', '.dropdown', function (e) { $(this).children('ul').removeAttr('') })
}(window.jQuery);

BIN
cache/fe5c4c7ec67e5277582208af3c5303b6 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,11 +1,12 @@
""" """
异步代理池 v4Cookie 感知 + 挑战分离 + 真实统计 异步代理池 v5P2C + 状态机 + 全量 5000 代理 + 并发限流
v4 vs v3: 对标 go3 的代理管理架构,适配 Python/curl_cffi
- 每个代理维护 cookie jarcf_clearance 等),成功请求后自动保存 - 全量加载 proxies.txt不再按端口过滤
- CF 挑战不再标记为代理死亡mark_challenged ≠ mark_failure - P2C (Power of Two Choices) 选择算法
- 真实统计:维护原子计数器,不造假数据 - 状态机: healthy → unstable → blocked → probing
- 更多 TLS 指纹:加入 safari15_5/safari17_0 - 并发限制 400Webshare 上限)
- 被动探测 + 指数退避
""" """
import asyncio import asyncio
import logging import logging
@@ -20,23 +21,42 @@ from curl_cffi.requests import AsyncSession
logger = logging.getLogger("ao3-proxy-pool") logger = logging.getLogger("ao3-proxy-pool")
OPTIMAL_MIN_PORT = 13500 # ─── Constants ───────────────────────────────────────────────────────────────
OPTIMAL_MAX_PORT = 14499
PROXY_FILE = "/home/ubuntu/proxy.txt"
WORKING_PROXIES_FILE = "/dev/shm/working_proxies.txt" WORKING_PROXIES_FILE = "/dev/shm/working_proxies.txt"
FAST_POOL_SIZE = 50 # Concurrency limit: Webshare allows ~500 concurrent, cap at 400 for safety
MAX_CONCURRENT_REQUESTS = 400
# 被动检查:只对快池做轻量采样 # Probe config (like go3: 10s interval, 10 concurrency, 2 successes to promote)
SAMPLE_INTERVAL = 60 PROBE_INTERVAL = 10
SAMPLE_BATCH_SIZE = 10 PROBE_CONCURRENCY = 10
SAMPLE_TIMEOUT = 5 PROBE_SUCCESS_THRESHOLD = 2
FAST_POOL_REFRESH_INTERVAL = 10 PROBE_TIMEOUT = 5
# TLS fingerprints — proven against AO3 Cloudflare # Cooldown config
# safari15_5/17_0 have highest CF bypass rate per testing BLOCKED_COOLDOWN_BASE = 120 # First block: 2 min
BLOCKED_COOLDOWN_MAX = 1800 # Max: 30 min
UNSTABLE_COOLDOWN = 15 # Transient failure: 15s
# Outage attack detection
OUTAGE_THRESHOLD = 0.9 # 90% proxies unavailable → attack page
# Max retries for a single request
MAX_RETRIES = 5
# Ban-worthy status codes
BAN_STATUS_CODES = {403, 525}
# TLS fingerprints (proven against AO3 CF)
BROWSER_IMPS = ["safari15_5", "safari17_0", "chrome123", "chrome124"] BROWSER_IMPS = ["safari15_5", "safari17_0", "chrome123", "chrome124"]
WARM_THRESHOLD_S = 3.0 # States
STATE_HEALTHY = "healthy"
STATE_UNSTABLE = "unstable"
STATE_BLOCKED = "blocked"
STATE_PROBING = "probing"
def _parse_cookie_expires(set_cookie: str) -> float: def _parse_cookie_expires(set_cookie: str) -> float:
@@ -55,23 +75,21 @@ def _parse_cookie_expires(set_cookie: str) -> float:
return time.time() + int(part[9:]) return time.time() + int(part[9:])
except Exception: except Exception:
pass pass
return 0 # session cookie return 0
def load_proxies_from_file() -> list[str]: def load_proxies_from_file() -> list[str]:
"""Load proxy list from known-working file, or full list with port filtering.""" """Load ALL proxies from file. No port filtering in v5."""
# Prefer cached working proxies if available and fresh
# But only use as a hint — still load the full list
if os.path.exists(WORKING_PROXIES_FILE) and os.path.getsize(WORKING_PROXIES_FILE) > 0: if os.path.exists(WORKING_PROXIES_FILE) and os.path.getsize(WORKING_PROXIES_FILE) > 0:
with open(WORKING_PROXIES_FILE) as f: with open(WORKING_PROXIES_FILE) as f:
proxies = [l.strip() for l in f if l.strip() and ":" in l] cached = [l.strip() for l in f if l.strip() and ":" in l]
if proxies: if len(cached) >= 100:
logger.info(f"Loaded {len(proxies)} working proxies from {WORKING_PROXIES_FILE}") logger.info(f"Found {len(cached)} cached working proxies — using as seed")
filtered = [p for p in proxies if ":" in p and OPTIMAL_MIN_PORT <= int(p.split(":")[-1]) <= OPTIMAL_MAX_PORT]
if len(filtered) >= 10:
return filtered
return proxies
proxy_file = "/home/ubuntu/proxy.txt"
proxies = [] proxies = []
with open(proxy_file) as f: with open(PROXY_FILE) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or ":" not in line: if not line or ":" not in line:
@@ -79,96 +97,133 @@ def load_proxies_from_file() -> list[str]:
if "|" in line: if "|" in line:
line = line.split("|")[-1].strip() line = line.split("|")[-1].strip()
proxies.append(line) proxies.append(line)
filtered = [p for p in proxies if ":" in p and OPTIMAL_MIN_PORT <= int(p.split(":")[-1]) <= OPTIMAL_MAX_PORT]
return filtered if len(filtered) >= 10 else proxies if not proxies:
raise RuntimeError(f"No proxies found in {PROXY_FILE}")
logger.info(f"Loaded {len(proxies)} proxies from {PROXY_FILE}")
return proxies
class ProxySession: class ProxySession:
"""A single proxy with its own AsyncSession, cookie jar, and health state.""" """A single proxy with its own AsyncSession, cookie jar, and state machine."""
__slots__ = ( __slots__ = (
"host_port", "host", "port_str", "port", "idx", "host_port", "host", "port_str", "port",
"session", "impersonate", "alive", "session", "impersonate",
"consecutive_failures", "ban_until", "last_used", # State machine
"avg_response_time", "weight", "requests_handled", "state", # healthy / unstable / blocked / probing
"last_sample", "sample_passed", # Stats
# v4: cookie jar "successes", "failures", "consecutive_net_failures",
"consecutive_blocked",
"last_used", "last_success_at", "last_failure_at",
"avg_response_time", "requests_handled",
# Cooldown / block
"cooldown_until", "next_probe_at",
"probe_successes",
# v5: inflight counter
"inflight",
# Cookie jar
"_cookies", "_cookie_expires", "_lock", "_cookies", "_cookie_expires", "_lock",
) )
def __init__(self, host_port: str): def __init__(self, idx: int, host_port: str):
self.idx = idx
self.host_port = host_port self.host_port = host_port
self.host, self.port_str = host_port.split(":") self.host, self.port_str = host_port.split(":")
self.port = int(self.port_str) self.port = int(self.port_str)
self.session: Optional[AsyncSession] = None self.session: Optional[AsyncSession] = None
self.impersonate = random.choice(BROWSER_IMPS) self.impersonate = random.choice(BROWSER_IMPS)
self.alive = True
self.consecutive_failures = 0 self.state = STATE_HEALTHY
self.ban_until = 0.0 self.successes = 0
self.failures = 0
self.consecutive_net_failures = 0
self.consecutive_blocked = 0
self.last_used = 0.0 self.last_used = 0.0
self.last_success_at = 0.0
self.last_failure_at = 0.0
self.avg_response_time = 1.0 self.avg_response_time = 1.0
self.weight = 1.0
self.requests_handled = 0 self.requests_handled = 0
self.last_sample = 0.0 self.cooldown_until = 0.0
self.sample_passed = True self.next_probe_at = 0.0
# v4: per-proxy cookie jar (cf_clearance, etc.) self.probe_successes = 0
self.inflight = 0
self._cookies: dict[str, str] = {} self._cookies: dict[str, str] = {}
self._cookie_expires: dict[str, float] = {} self._cookie_expires: dict[str, float] = {}
self._lock = Lock() self._lock = Lock()
# ─── Cookie management ──────────────────────────────────────────── @property
def is_available(self) -> bool:
def save_cookies(self, headers: dict) -> int: """Proxy is selectable for user requests."""
"""Extract Set-Cookie from response headers. Returns count saved."""
saved = 0
set_cookie = headers.get("Set-Cookie", "")
if not set_cookie:
# Some servers use set-cookie (lowercase) in HTTP/2
set_cookie = headers.get("set-cookie", "")
if not set_cookie:
return 0
now = time.time() now = time.time()
with self._lock: if now < self.cooldown_until:
for part in set_cookie.split(","): return False
# Handle comma-separated cookies (ugh) if self.state == STATE_HEALTHY:
part = part.strip() return True
if "=" not in part: if self.state == STATE_UNSTABLE and now >= self.cooldown_until:
continue return True
name, _, rest = part.partition("=") if self.state == STATE_PROBING:
value = rest.split(";")[0].strip() if ";" in rest else rest.strip() return False # Don't route user traffic to probing proxies
name = name.strip() return False # STATE_BLOCKED
if not name:
continue
self._cookies[name] = value
expires = _parse_cookie_expires(part)
if expires > 0:
self._cookie_expires[name] = expires
saved += 1
# Purge expired cookies @property
self._purge_expired(now) def is_user_selectable(self) -> bool:
if saved: """Proxy can be chosen for sticky/user requests. Like go3's isUserSelectable."""
logger.debug(f"Saved {saved} cookies for {self.host_port} (keys: {list(self._cookies.keys())})") return self.is_available
return saved
def get_cookie_header(self) -> str:
"""Get Cookie header string for this proxy. Returns '' if no cookies."""
now = time.time()
with self._lock:
self._purge_expired(now)
if not self._cookies:
return ""
return "; ".join(f"{k}={v}" for k, v in self._cookies.items())
def _purge_expired(self, now: float): def _purge_expired(self, now: float):
"""Remove expired cookies."""
expired = [k for k, exp in self._cookie_expires.items() if 0 < exp < now] expired = [k for k, exp in self._cookie_expires.items() if 0 < exp < now]
for k in expired: for k in expired:
self._cookies.pop(k, None) self._cookies.pop(k, None)
self._cookie_expires.pop(k, None) self._cookie_expires.pop(k, None)
# ─── Session management ─────────────────────────────────────────── def save_cookies(self, headers: dict) -> int:
"""Extract ONLY cf_clearance from response headers. Returns count saved.
The proxy must be TRANSPARENT to AO3 — do NOT save _otwarchive_session,
user_credentials, or any other AO3 cookies. Only cf_clearance (Cloudflare
bypass) is our concern. Everything else passes through to the browser.
"""
set_cookie = headers.get("Set-Cookie", headers.get("set-cookie", ""))
if not set_cookie:
return 0
now = time.time()
with self._lock:
parts = set_cookie.split(";")
if parts:
first_part = parts[0].strip()
if "=" in first_part:
name, _, value = first_part.partition("=")
name = name.strip()
value = value.strip()
# ONLY save cf_clearance — the only cookie the proxy needs
if name == "cf_clearance":
self._cookies[name] = value
logger.info(f"[CF_SAVE] {self.host_port}: saved cf_clearance")
expires = _parse_cookie_expires(set_cookie)
if expires > 0:
self._cookie_expires[name] = expires
self._purge_expired(now)
return 1
else:
logger.info(f"[CF_SKIP] {self.host_port}: skipping {name} (not cf_clearance)")
self._purge_expired(now)
return 0
def get_cookie_header(self) -> str:
now = time.time()
with self._lock:
self._purge_expired(now)
if not self._cookies:
return ""
header = "; ".join(f"{k}={v}" for k, v in self._cookies.items())
logger.info(f"[PROXY_COOKIES] {self.host_port}: {list(self._cookies.keys())[:10]}")
return header
def has_cookies(self) -> bool:
return bool(self._cookies)
async def get_session(self) -> AsyncSession: async def get_session(self) -> AsyncSession:
if self.session is None: if self.session is None:
@@ -179,10 +234,11 @@ class ProxySession:
"http": f"http://{self.host_port}", "http": f"http://{self.host_port}",
"https": f"http://{self.host_port}", "https": f"http://{self.host_port}",
} }
# Default headers that don't change per-request (safe to set on session)
self.session.headers.update({ self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "User-Agent": (
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "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", "Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br", "Accept-Encoding": "gzip, deflate, br",
@@ -197,260 +253,364 @@ class ProxySession:
pass pass
self.session = None self.session = None
# ─── Health tracking ────────────────────────────────────────────── # ─── State machine (go3-compatible) ──────────────────────────────────
def update_weight(self):
self.weight = 1.0 / max(self.avg_response_time, 0.1)
def mark_success(self, response_time: float): def mark_success(self, response_time: float):
self.alive = True """Request succeeded. Promote to healthy."""
self.consecutive_failures = 0 self.last_used = time.time()
self.ban_until = 0.0 self.last_success_at = time.time()
self.successes += 1
self.requests_handled += 1 self.requests_handled += 1
self.consecutive_net_failures = 0
self.consecutive_blocked = 0
self.avg_response_time = self.avg_response_time * 0.7 + response_time * 0.3 self.avg_response_time = self.avg_response_time * 0.7 + response_time * 0.3
self.update_weight() self.cooldown_until = 0.0
self.state = STATE_HEALTHY
def mark_failure(self): def mark_failure(self, is_network: bool = True):
"""Proxy-level failure (connection error, timeout, etc.) — exponential backoff.""" """Connection error / timeout. Exponential backoff."""
now = time.time()
self.last_used = now
self.last_failure_at = now
self.failures += 1
self.requests_handled += 1 self.requests_handled += 1
self.consecutive_failures += 1
backoff = min(5 * (3 ** (self.consecutive_failures - 1)), 300) if is_network:
self.ban_until = time.time() + backoff self.consecutive_net_failures += 1
if self.consecutive_failures >= 3: self.consecutive_blocked = 0
self.alive = False
self.sample_passed = False if self.consecutive_net_failures >= 3:
self.state = STATE_BLOCKED
# Exponential backoff: base * 2^(failures-1), capped at max
backoff = min(
BLOCKED_COOLDOWN_BASE * (2 ** (self.consecutive_net_failures - 3)),
BLOCKED_COOLDOWN_MAX
)
self.cooldown_until = now + backoff
self.next_probe_at = now + backoff
self.probe_successes = 0
elif self.consecutive_net_failures >= 1:
self.state = STATE_UNSTABLE
self.cooldown_until = now + UNSTABLE_COOLDOWN
# else stay healthy / unchanged
def mark_blocked(self):
"""403/525 — CF blocked. Move to blocked state."""
now = time.time()
self.last_used = now
self.last_failure_at = now
self.failures += 1
self.requests_handled += 1
self.consecutive_blocked += 1
self.consecutive_net_failures = 0
self.state = STATE_BLOCKED
backoff = min(
BLOCKED_COOLDOWN_BASE * (2 ** (self.consecutive_blocked - 1)),
BLOCKED_COOLDOWN_MAX
)
self.cooldown_until = now + backoff
self.next_probe_at = now + backoff
self.probe_successes = 0
def mark_challenged(self): def mark_challenged(self):
"""CF challenge detected — proxy is alive but needs cookie. NO backoff.""" """CF challenge detected — proxy is alive but needs cookie. Keep healthy, don't penalize."""
# Don't increment consecutive_failures — challenge is not proxy's fault self.last_used = time.time()
# Don't set ban_until — proxy may work with cookies
self.requests_handled += 1 self.requests_handled += 1
# Only mark as not-sample-passed so it won't be fast-pool priority # Don't change state — challenge is not proxy's fault
self.sample_passed = False
def mark_sample(self, passed: bool, response_time: float = 0): def mark_probe_result(self, passed: bool, response_time: float = 0):
"""Lightweight periodic check result.""" """Periodic probe result. If probing and enough successes, promote to healthy."""
self.last_sample = time.time() now = time.time()
self.sample_passed = passed if passed:
if passed and not self.alive: self.probe_successes += 1
self.alive = True if self.probe_successes >= PROBE_SUCCESS_THRESHOLD:
self.consecutive_failures = max(0, self.consecutive_failures - 1) self.state = STATE_HEALTHY
self.avg_response_time = self.avg_response_time * 0.5 + response_time * 0.5 self.cooldown_until = 0.0
self.update_weight() self.consecutive_net_failures = 0
self.consecutive_blocked = 0
self.avg_response_time = self.avg_response_time * 0.5 + response_time * 0.5
else:
self.probe_successes = 0
# Extend cooldown
self.cooldown_until = now + max(self.cooldown_until - now, BLOCKED_COOLDOWN_BASE)
self.next_probe_at = 0.0
@property def to_stats_dict(self) -> dict:
def is_available(self) -> bool: return {
return self.alive and time.time() > self.ban_until "address": self.host_port,
"state": self.state,
"successes": self.successes,
"failures": self.failures,
"consecutive_net_failures": self.consecutive_net_failures,
"consecutive_blocked": self.consecutive_blocked,
"last_used": self.last_used,
"last_success_at": self.last_success_at,
"last_failure_at": self.last_failure_at,
"avg_response_time": round(self.avg_response_time, 3),
"requests_handled": self.requests_handled,
"cooldown_until": self.cooldown_until,
"inflight": self.inflight,
"has_cookies": bool(self._cookies),
"impersonate": self.impersonate,
}
def __repr__(self): def __repr__(self):
nc = len(self._cookies) return f"PS({self.host_port}, {self.state}, {self.avg_response_time:.2f}s, inflight={self.inflight})"
return f"PS({self.host_port}, alive={self.alive}, {self.avg_response_time:.1f}s, cookies={nc})"
class AsyncProxyPool: class AsyncProxyPool:
"""Tiered async proxy pool with cookie-aware session management.""" """Proxy pool v5 — P2C selection + state machine + concurrent limiter."""
def __init__(self): def __init__(self):
self._proxies: list[ProxySession] = [] self._proxies: list[ProxySession] = []
self._fast_pool: list[ProxySession] = [] self._idx_map: dict[int, ProxySession] = {}
self._fast_pool_updated = 0.0 self._addr_map: dict[str, ProxySession] = {}
self._sample_task: Optional[asyncio.Task] = None
# v4: atomic counters for real stats (no more fake data) # Concurrent request limiter (capped at 400)
self._concurrency_limiter = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
# Stats
self._stats_lock = Lock() self._stats_lock = Lock()
self._stats = {"alive": 0, "dead": 0, "banned": 0, "challenged": 0}
self._stats_cache = {} # Probe task
self._stats_cache_ts = 0.0 self._probe_task: Optional[asyncio.Task] = None
self._probe_running = False
# Load proxies
self._load_proxies() self._load_proxies()
self._start_sampling() self._start_probing()
logger.info(
f"ProxyPool v5 ready: {len(self._proxies)} proxies, "
f"concurrency={MAX_CONCURRENT_REQUESTS}, selection=p2c"
)
def _load_proxies(self): def _load_proxies(self):
proxies = load_proxies_from_file() proxy_list = load_proxies_from_file()
self._proxies = [ProxySession(hp) for hp in proxies] self._proxies = []
self._refresh_fast_pool() self._idx_map = {}
self._recompute_stats() self._addr_map = {}
logger.info(f"ProxyPool v4 ready: {len(self._proxies)} proxies, fast={len(self._fast_pool)}") for i, hp in enumerate(proxy_list):
ps = ProxySession(i, hp)
self._proxies.append(ps)
self._idx_map[i] = ps
self._addr_map[hp] = ps
logger.info(f"Loaded {len(self._proxies)} proxies")
def _start_sampling(self): def _start_probing(self):
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
except RuntimeError: except RuntimeError:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
if self._sample_task is None or self._sample_task.done(): self._probe_task = asyncio.create_task(self._probe_loop())
self._sample_task = asyncio.create_task(self._sampling_loop())
def _refresh_fast_pool(self): # ─── Proxy selection (P2C — Power of Two Choices) ───────────────────
"""Select fastest N proxies, seeded immediately from all alive."""
alive = [p for p in self._proxies if p.is_available]
sampled = [p for p in alive if p.sample_passed]
unsampled = [p for p in alive if not p.sample_passed]
sampled.sort(key=lambda p: p.avg_response_time)
unsampled.sort(key=lambda p: p.avg_response_time)
combined = sampled + unsampled
self._fast_pool = combined[:FAST_POOL_SIZE]
self._fast_pool_updated = time.time()
def _recompute_stats(self): def p2c_select(self, exclude_idxs: Optional[set[int]] = None) -> Optional[ProxySession]:
"""Accurate stats — iterate all proxies (fast, ~726 items).""" """
alive = 0 Power of Two Choices: pick 2 random candidates, return the one with fewer inflight.
dead = 0 Falls back to random if P2C produces ties.
banned = 0 """
for p in self._proxies: exclude = exclude_idxs or set()
if p.alive:
alive += 1
if not p.is_available:
banned += 1
else:
dead += 1
with self._stats_lock:
self._stats = {"alive": alive, "dead": dead, "banned": banned}
# ─── Proxy selection ──────────────────────────────────────────────
def get_fast_proxy(self) -> Optional[ProxySession]:
"""Fast proxy for interactive requests (login/register/POST)."""
now = time.time() now = time.time()
if now - self._fast_pool_updated > FAST_POOL_REFRESH_INTERVAL:
self._refresh_fast_pool()
if not self._fast_pool:
return self.get_proxy()
total = sum(p.weight for p in self._fast_pool)
if total <= 0:
return random.choice(self._fast_pool)
r = random.uniform(0, total)
cum = 0
for p in self._fast_pool:
cum += p.weight
if r <= cum:
return p
return random.choice(self._fast_pool)
def get_proxy(self) -> Optional[ProxySession]: # Build candidate list (selectable, not excluded)
"""Weighted random from all available proxies.""" candidates = []
available = [p for p in self._proxies if p.is_available] for p in self._proxies:
if not available: if p.idx in exclude:
# Fallback: use proxies with fewer than 10 failures continue
available = [p for p in self._proxies if p.consecutive_failures < 10] if not p.is_user_selectable:
if not available: continue
if p.inflight >= 10: # Don't overload a single proxy
continue
candidates.append(p)
if not candidates:
# Fallback: anything with cooldown expired, even if probing
for p in self._proxies:
if p.idx in exclude:
continue
if now >= p.cooldown_until and p.inflight < 10:
candidates.append(p)
if not candidates:
# Desperate: any proxy
for p in self._proxies:
if p.idx in exclude:
continue
if p.inflight < 10:
candidates.append(p)
if not candidates:
return None return None
total = sum(p.weight for p in available)
if total <= 0:
return random.choice(available)
r = random.uniform(0, total)
cum = 0
for p in available:
cum += p.weight
if r <= cum:
return p
return random.choice(available)
def get_proxy_with_cookies(self) -> Optional[ProxySession]: if len(candidates) == 1:
"""Get a proxy that has cookies saved (cf_clearance). Fallback to any available.""" return candidates[0]
available = [p for p in self._proxies if p.is_available]
with_cookies = [p for p in available if p._cookies]
if with_cookies:
total = sum(p.weight for p in with_cookies)
if total > 0:
r = random.uniform(0, total)
cum = 0
for p in with_cookies:
cum += p.weight
if r <= cum:
return p
return random.choice(with_cookies)
return self.get_proxy()
# ─── Sampling loop ──────────────────────────────────────────────── # P2C: pick 2, choose the one with lower inflight
left = random.choice(candidates)
right = random.choice(candidates)
# Avoid same proxy
for _ in range(5):
if right.idx != left.idx:
break
right = random.choice(candidates)
async def _sampling_loop(self): if left.inflight <= right.inflight:
"""Lightweight sampling — only test fast-pool proxies.""" return left
logger.info("Sampling loop started (lightweight, fast-pool only)") return right
def get_by_idx(self, idx: int) -> Optional[ProxySession]:
return self._idx_map.get(idx)
def get_by_addr(self, addr: str) -> Optional[ProxySession]:
return self._addr_map.get(addr)
# ─── Sticky proxy ───────────────────────────────────────────────────
def sticky_select(self, sticky_idx: int, exclude_idxs: set[int]) -> Optional[ProxySession]:
"""Try to use the sticky proxy if it's still selectable."""
p = self.get_by_idx(sticky_idx)
if p and p.is_user_selectable and p.idx not in exclude_idxs and p.inflight < 10:
return p
return None
# ─── Stats ──────────────────────────────────────────────────────────
def get_stats(self) -> dict:
total = len(self._proxies)
healthy = 0
unstable = 0
blocked = 0
probing = 0
total_inflight = 0
with_cookies = 0
total_requests = 0
response_times = []
for p in self._proxies:
if p.state == STATE_HEALTHY:
healthy += 1
elif p.state == STATE_UNSTABLE:
unstable += 1
elif p.state == STATE_BLOCKED:
blocked += 1
elif p.state == STATE_PROBING:
probing += 1
total_inflight += p.inflight
if p.has_cookies():
with_cookies += 1
total_requests += p.requests_handled
if p.requests_handled > 0:
response_times.append(p.avg_response_time)
avg_rt = sum(response_times) / max(len(response_times), 1)
return {
"total": total,
"healthy": healthy,
"unstable": unstable,
"blocked": blocked,
"probing": probing,
"available": healthy, # Only healthy are truly available
"total_inflight": total_inflight,
"with_cookies": with_cookies,
"total_requests": total_requests,
"avg_response_time_s": round(avg_rt, 3),
"pool_health": round(healthy / max(total, 1), 3),
"pool_unavailable_ratio": round((blocked + probing) / max(total, 1), 3),
}
def get_proxy_stats_list(self) -> list[dict]:
"""Detailed per-proxy stats for the monitor."""
return [p.to_stats_dict() for p in self._proxies]
def pool_available_ratio(self) -> float:
"""Fraction of proxies currently selectable."""
available = sum(1 for p in self._proxies if p.is_user_selectable)
return available / max(len(self._proxies), 1)
def pool_unavailable_ratio(self) -> float:
return 1.0 - self.pool_available_ratio()
# ─── Concurrency limiter ────────────────────────────────────────────
@property
def concurrency_limiter(self) -> asyncio.Semaphore:
return self._concurrency_limiter
# ─── Probe loop ─────────────────────────────────────────────────────
async def _probe_loop(self):
"""Periodic probe to recover blocked proxies. Like go3's probe goroutine."""
await asyncio.sleep(5) # Wait for initial startup
logger.info("Probe loop started")
while True: while True:
try: try:
await asyncio.sleep(SAMPLE_INTERVAL) await asyncio.sleep(PROBE_INTERVAL)
pool = self._fast_pool[:] if self._fast_pool else self._proxies[:50] # Find proxies that need probing: blocked/probing with cooldown expired
if not pool: now = time.time()
to_probe = []
for p in self._proxies:
if p.state in (STATE_BLOCKED, STATE_PROBING):
if now >= p.cooldown_until and now >= p.next_probe_at:
to_probe.append(p)
if not to_probe:
continue continue
alive_cnt = 0
dead_cnt = 0 # Probe in batches of PROBE_CONCURRENCY
for i in range(0, len(pool), SAMPLE_BATCH_SIZE): random.shuffle(to_probe)
batch = pool[i:i + SAMPLE_BATCH_SIZE] for i in range(0, len(to_probe), PROBE_CONCURRENCY):
checks = [self._check_single(p) for p in batch] batch = to_probe[i:i + PROBE_CONCURRENCY]
results = await asyncio.gather(*checks, return_exceptions=True) tasks = [self._probe_single(p) for p in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for p, r in zip(batch, results): for p, r in zip(batch, results):
if isinstance(r, Exception): if isinstance(r, Exception):
p.mark_sample(False) p.mark_probe_result(False)
dead_cnt += 1
elif r[0]:
p.mark_sample(True, r[1])
alive_cnt += 1
else: else:
p.mark_sample(False) p.mark_probe_result(r[0], r[1])
dead_cnt += 1
self._refresh_fast_pool() # Log recovery stats
self._recompute_stats() recovered = sum(1 for p in to_probe if p.state == STATE_HEALTHY)
total_avg = sum(p.avg_response_time for p in pool if p.requests_handled > 0) / max(alive_cnt, 1) if recovered:
logger.debug(f"Sample: {alive_cnt} alive, {dead_cnt} dead, fast={len(self._fast_pool)}, avg={total_avg*1000:.0f}ms") logger.info(f"Probe: {len(to_probe)} checked, {recovered} recovered")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"Sample error: {e}") logger.error(f"Probe error: {e}")
async def _check_single(self, proxy: ProxySession) -> tuple[bool, float]: async def _probe_single(self, proxy: ProxySession) -> tuple[bool, float]:
"""Quick single-proxy check against AO3.""" """Quick HEAD check against AO3."""
start = time.time() start = time.time()
try: try:
s = await proxy.get_session() s = await proxy.get_session()
resp = await s.head("https://archiveofourown.org/", timeout=SAMPLE_TIMEOUT) resp = await s.head("https://archiveofourown.org/", timeout=PROBE_TIMEOUT)
return (200 <= resp.status_code < 500, time.time() - start) ok = 200 <= resp.status_code < 500
return (ok, time.time() - start)
except Exception: except Exception:
return (False, time.time() - start) return (False, time.time() - start)
# ─── Lifecycle ──────────────────────────────────────────────────── # ─── Lifecycle ──────────────────────────────────────────────────────
async def close_all(self): async def close_all(self):
if self._sample_task and not self._sample_task.done(): if self._probe_task and not self._probe_task.done():
self._sample_task.cancel() self._probe_task.cancel()
try: try:
await self._sample_task await self._probe_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
for p in self._proxies: for p in self._proxies:
await p.close() await p.close()
logger.info("All sessions closed") logger.info("All proxy sessions closed")
# ─── Stats ──────────────────────────────────────────────────────── @property
def proxies(self) -> list[ProxySession]:
return self._proxies
def get_stats(self) -> dict: def __len__(self):
"""Accurate stats with caching (1s throttle to avoid iteration on every call).""" return len(self._proxies)
now = time.time()
if self._stats_cache and now - self._stats_cache_ts < 1.0:
return self._stats_cache
self._recompute_stats()
total = len(self._proxies)
# Compute avg response from sampled proxies
sampled = [p.avg_response_time for p in self._proxies if p.requests_handled > 0]
avg_speed = sum(sampled) / max(len(sampled), 1)
# Count proxies with cookies
with_cookies = sum(1 for p in self._proxies if p._cookies)
result = {
"total": total,
"alive": self._stats["alive"],
"dead": self._stats["dead"],
"banned": self._stats["banned"],
"available": self._stats["alive"] - self._stats["banned"],
"warm": sum(1 for p in self._fast_pool if p.sample_passed),
"fast_pool": len(self._fast_pool),
"with_cookies": with_cookies,
"total_requests_handled": sum(p.requests_handled for p in self._proxies),
"avg_response_time_s": round(avg_speed, 3),
}
self._stats_cache = result
self._stats_cache_ts = now
return result
# ─── Singleton ──────────────────────────────────────────────────────────────── # ─── Singleton ────────────────────────────────────────────────────────────────

235
static/sw.js Normal file
View File

@@ -0,0 +1,235 @@
/* AO3 Mirror Service Worker v5 — Client-side cache + offline fallback + navigation intercept
*
* Deployed at /sw-YYYYMMDD.js (versioned), /sw.js redirects to latest.
* Server injects <script>navigator.serviceWorker.register('/sw.js')</script> into HTML pages.
*/
'use strict';
const MIRROR_DOMAINS = ['agento3.miscs.dev'];
const PRIMARY_DOMAIN = 'agento3.miscs.dev';
// Static asset types to cache aggressively
const STATIC_EXTENSIONS = [
'.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico',
'.woff', '.woff2', '.ttf', '.svg', '.webp', '.json',
];
// Cache names
const STATIC_CACHE = 'ao3-static-v1';
const HTML_CACHE = 'ao3-html-v1';
// Navigation timeout (25s, like go3)
const NAV_FETCH_TIMEOUT_MS = 25000;
// ─── Helpers ──────────────────────────────────────────────────────────
function primaryMirrorHost() {
try {
var scopeHost = new URL(self.registration.scope).hostname;
if (scopeHost && scopeHost.indexOf('.') !== -1) return scopeHost;
} catch (e) {}
for (var i = 0; i < MIRROR_DOMAINS.length; i++) {
var h = MIRROR_DOMAINS[i];
if (h && h.indexOf('.') !== -1) return h;
}
return PRIMARY_DOMAIN;
}
function isStaticAsset(url) {
var path = url.pathname.toLowerCase();
for (var i = 0; i < STATIC_EXTENSIONS.length; i++) {
if (path.endsWith(STATIC_EXTENSIONS[i])) return true;
}
return false;
}
function hostFromRequest(request) {
try {
return new URL(request.url).hostname || '';
} catch (e) {
return '';
}
}
function repairNavigationURL(url) {
var host = url.hostname;
if (!host || host.indexOf('.') !== -1 || host === 'localhost') return url;
var mirror = primaryMirrorHost();
if (!mirror) return url;
var fixed = new URL(url.toString());
fixed.hostname = mirror;
fixed.pathname = '/' + host + (fixed.pathname || '/');
return fixed;
}
// ─── Offline / mirror picker page ─────────────────────────────────────
function buildMirrorPickerPage(currentHost, mirrors) {
var mirrorItems = mirrors
.filter(function (h) { return h; })
.map(function (h) {
return '<li><a href="https://' + h + '/' + '">' + h + '</a></li>';
})
.join('');
return (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>无法访问镜像站点</title>' +
'<style>' +
'body{margin:0;background:#f6f6f6;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#222}' +
'.wrap{max-width:680px;margin:40px auto;padding:0 16px}' +
'.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:24px 28px;box-shadow:0 6px 20px rgba(0,0,0,.05)}' +
'h1{margin:0 0 8px;color:#860000;font-size:22px}' +
'.lead{color:#555;line-height:1.6;margin:0 0 20px}' +
'.steps{margin:0;padding:0;list-style:none}' +
'.steps>li{margin:0 0 20px;padding:0 0 20px;border-bottom:1px solid #eee}' +
'.steps>li:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}' +
'.step-title{display:flex;align-items:flex-start;gap:10px;font-weight:600;color:#333;margin:0 0 8px;line-height:1.5}' +
'.step-num{flex-shrink:0;width:26px;height:26px;border-radius:50%;background:#860000;color:#fff;font-size:14px;line-height:26px;text-align:center}' +
'.step-body{color:#555;line-height:1.65;margin:0;font-size:15px}' +
'.mirrors{margin:8px 0 0;padding-left:20px}' +
'.mirrors li{margin:8px 0}' +
'a{color:#900;text-decoration:none;font-weight:500}a:hover{text-decoration:underline}' +
'code{background:#f3f3f3;padding:2px 6px;border-radius:4px;font-size:.92em}' +
'.muted{color:#888;font-size:13px;line-height:1.6;margin-top:20px;padding-top:16px;border-top:1px solid #eee}' +
'</style></head><body><div class="wrap"><div class="card">' +
'<h1>无法连接到镜像站点</h1>' +
'<p class="lead">浏览器未能与 <code>' + currentHost + '</code> 建立网络连接例如断网、DNS 失败或被防火墙拦截)。请按下面顺序逐步排查。</p>' +
'<ol class="steps">' +
'<li><p class="step-title"><span class="step-num">1</span><span>先检查网络连接</span></p>' +
'<p class="step-body">确认设备已联网:可尝试打开其他网站或 App。若使用 WiFi请检查路由器是否正常。</p></li>' +
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 WiFi使用手机 <strong>4G / 5G 流量</strong> 重新访问。</p></li>' +
'<li><p class="step-title"><span class="step-num">3</span><span>尝试备用域名</span></p>' +
'<p class="step-body">点击下方备用镜像站点:</p>' +
'<ul class="mirrors">' + mirrorItems + '</ul></li>' +
'</ol>' +
'<p class="muted">页面由 AO3 Mirror Service Worker 提供。若以上步骤后仍无法打开,请稍后再试。</p>' +
'</div></div></body></html>'
);
}
function offlineGuideResponse(currentHost) {
return new Response(buildMirrorPickerPage(currentHost, MIRROR_DOMAINS), {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
// ─── Network strategies ───────────────────────────────────────────────
function handleNavigation(request) {
var currentHost = hostFromRequest(request);
var url = new URL(request.url);
// Repair malformed navigation URLs
var navUrl = repairNavigationURL(url);
var fetchInit = {};
if (navUrl.href !== request.url) {
fetchInit = {
method: request.method,
headers: request.headers,
credentials: request.credentials,
redirect: 'follow',
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
};
}
var ctrl = new AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, NAV_FETCH_TIMEOUT_MS);
var fetchTarget = navUrl.href !== request.url ? new Request(navUrl.href, fetchInit) : request;
return fetch(fetchTarget, { signal: ctrl.signal })
.finally(function () { clearTimeout(timer); })
.then(function (response) {
if (response.redirected && response.url) {
return Response.redirect(response.url, 302);
}
return response;
})
.catch(function () {
return offlineGuideResponse(currentHost);
});
}
function handleStatic(request) {
// Cache-first for static assets
return caches.open(STATIC_CACHE).then(function (cache) {
return cache.match(request).then(function (cached) {
if (cached) {
// Background revalidation
fetch(request).then(function (response) {
if (response && response.ok) {
cache.put(request, response);
}
}).catch(function () {});
return cached;
}
// Network with cache fallback
return fetch(request).then(function (response) {
if (response && response.ok) {
var cloned = response.clone();
cache.put(request, cloned);
}
return response;
}).catch(function () {
// Offline — return whatever we have
return cache.match(request);
});
});
});
}
// ─── Install / Activate ───────────────────────────────────────────────
self.addEventListener('install', function (event) {
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
// Clean old caches
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.map(function (key) {
if (key !== STATIC_CACHE && key !== HTML_CACHE) {
return caches.delete(key);
}
})
);
})
);
});
// ─── Fetch handler ────────────────────────────────────────────────────
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
var url = new URL(event.request.url);
// Don't intercept SW or monitor paths
if (url.pathname.startsWith('/sw') || url.pathname === '/mirror-domains.json') return;
if (url.pathname === '/_monitor' || url.pathname.startsWith('/_monitor/')) return;
if (url.pathname === '/stats' || url.pathname === '/metrics' || url.pathname === '/health') return;
// Static assets: cache-first
if (isStaticAsset(url)) {
event.respondWith(handleStatic(event.request));
return;
}
// Navigation (HTML pages): network-first with offline fallback
if (event.request.mode === 'navigate' || event.request.destination === 'document') {
event.respondWith(
handleNavigation(event.request).catch(function () {
return offlineGuideResponse(hostFromRequest(event.request));
})
);
}
// Other requests pass through to server normally
});

View File

@@ -83,6 +83,30 @@ def rewrite_response_headers(headers: dict) -> dict:
return new_headers return new_headers
def rewrite_response_headers_raw(headers_raw: list) -> list:
"""Rewrite AO3 domains in raw response headers (preserves multi-value Set-Cookie).
Unlike rewrite_response_headers which works on a dict (collapsing multi-value
Set-Cookie), this operates on a list of (key, value) tuples so ALL Set-Cookie
values are preserved and individually rewritten.
"""
result = []
for key, value in headers_raw:
key_lower = key.lower()
if key_lower == "location":
value = value.replace(TARGET_DOMAIN, MIRROR_DOMAIN)
value = value.replace("http://", "https://")
elif key_lower == "set-cookie":
value = value.replace(f"domain={TARGET_DOMAIN}", f"domain={MIRROR_DOMAIN}")
value = value.replace(f"Domain={TARGET_DOMAIN}", f"Domain={MIRROR_DOMAIN}")
result.append((key, value))
return result
def rewrite_redirect_url(url: str) -> str: def rewrite_redirect_url(url: str) -> str:
"""Rewrite AO3 URLs in redirect targets.""" """Rewrite AO3 URLs in redirect targets."""
return url.replace(TARGET_DOMAIN, MIRROR_DOMAIN).replace("http://", "https://") return url.replace(TARGET_DOMAIN, MIRROR_DOMAIN).replace("http://", "https://")