Spaces:
Running
Running
File size: 9,870 Bytes
aa0a3e7 7963191 aa0a3e7 7963191 aa0a3e7 7963191 aa0a3e7 7a1bb2f 7963191 aa0a3e7 7963191 aa0a3e7 7963191 aa0a3e7 7a1bb2f aa0a3e7 7963191 aa0a3e7 7963191 aa0a3e7 7963191 aa0a3e7 7963191 aa0a3e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | import hashlib
import ipaddress
import json
import os
import re
import threading
import time
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from . import parse_core, pipeline_dispatch, source_files
from .doc_store import parse_queue_docs
from .document_identity import content_hash
from .parse_core import ParseRequest
from .storage_lock import serving_lock
router = APIRouter()
GITHUB_TOKEN = os.environ.get("BHA_PROOFREAD_GITHUB_TOKEN", "")
PIPELINE_REPOSITORY = os.environ.get("BHA_PROOFREAD_PIPELINE_REPOSITORY", "anftm/pipeline")
PARSE_ARCHIVE_ID = 25
PARSE_QUEUE_PREFIX = "【文章待拆分】"
PARSE_OCR_BASE_URL = os.environ.get(
"BHA_PARSE_OCR_BASE_URL",
"https://raw.githubusercontent.com/banned-historical-archives/banned-historical-archives25/ocr_cache",
).rstrip("/")
PARSE_OCR_MAX_BYTES = 2 * 1024 * 1024
PARSE_MAX_PAYLOAD_CHARACTERS = 2_000_000
PARSE_RATE_LIMIT = 30
PARSE_RATE_WINDOW_SECONDS = 60 * 60
TRUST_PROXY = os.environ.get("BHA_TRUST_PROXY", "1" if os.environ.get("SPACE_ID") else "0") == "1"
try:
PROXY_NETWORKS = tuple(
ipaddress.ip_network(value.strip())
for value in os.environ.get("BHA_TRUST_PROXY_CIDRS", "").split(",")
if value.strip()
)
except ValueError:
PROXY_NETWORKS = ()
try:
PROXY_HOPS = max(1, int(os.environ.get("BHA_TRUST_PROXY_HOPS", "1")))
except ValueError:
PROXY_HOPS = 1
parse_requests: dict[str, list[float]] = {}
parse_rate_lock = threading.Lock()
parse_dispatches: dict[str, dict[str, Any]] = {}
parse_dispatch_lock = threading.Lock()
async def dispatch_parse(payload: dict[str, Any]) -> None:
await pipeline_dispatch.dispatch(payload, "submit-parse", GITHUB_TOKEN, PIPELINE_REPOSITORY)
def parse_client_key(request: Request) -> str:
peer = request.client.host if request.client else "unknown"
trusted_peer = False
try:
peer_address = ipaddress.ip_address(peer)
trusted_peer = TRUST_PROXY and bool(PROXY_NETWORKS) and any(peer_address in network for network in PROXY_NETWORKS)
except ValueError:
pass
forwarded = request.headers.get("x-forwarded-for", "") if trusted_peer else ""
candidates = [value.strip() for value in forwarded.split(",") if value.strip()]
if candidates and len(candidates) > PROXY_HOPS:
candidate = candidates[-PROXY_HOPS - 1]
try:
ipaddress.ip_address(candidate)
return candidate
except ValueError:
pass
return peer
def parse_rate_error(request: Request) -> JSONResponse | None:
key = parse_client_key(request)
now = time.monotonic()
with parse_rate_lock:
recent = [value for value in parse_requests.get(key, []) if now - value < PARSE_RATE_WINDOW_SECONDS]
if len(recent) >= PARSE_RATE_LIMIT:
return JSONResponse({"error": "parse rate limit exceeded"}, status_code=429)
recent.append(now)
parse_requests[key] = recent
if len(parse_requests) > 10000:
for identity in list(parse_requests):
parse_requests[identity] = [value for value in parse_requests[identity] if now - value < PARSE_RATE_WINDOW_SECONDS]
if not parse_requests[identity]:
parse_requests.pop(identity, None)
while len(parse_requests) > 10000:
oldest = min(parse_requests, key=lambda identity: min(parse_requests[identity]))
parse_requests.pop(oldest, None)
request.state.parse_rate_key = key
request.state.parse_rate_timestamp = now
return None
def release_parse_rate(request: Request) -> None:
key = getattr(request.state, "parse_rate_key", None)
timestamp = getattr(request.state, "parse_rate_timestamp", None)
if key is None or timestamp is None:
return
with parse_rate_lock:
values = parse_requests.get(key, [])
if timestamp in values:
values.remove(timestamp)
if values:
parse_requests[key] = values
else:
parse_requests.pop(key, None)
def duplicate_parse_response(request_id: str) -> JSONResponse | None:
now = time.time()
with parse_dispatch_lock:
for key, value in list(parse_dispatches.items()):
if now - float(value["created_at"]) > 86400:
parse_dispatches.pop(key, None)
existing = parse_dispatches.get(request_id)
if existing is not None:
return JSONResponse({
"ok": True,
"status": "duplicate",
"original_status": existing["status"],
"request_id": request_id,
"tasks": 1,
})
if len(parse_dispatches) >= 1000:
oldest = min(parse_dispatches, key=lambda key: parse_dispatches[key]["created_at"])
parse_dispatches.pop(oldest, None)
parse_dispatches[request_id] = {"created_at": now, "status": "processing"}
return None
def parse_queue_source(doc_id: str) -> dict[str, Any] | None:
source = source_files.get_source_doc(doc_id)
if not parse_core.is_queue_source(source, PARSE_ARCHIVE_ID, PARSE_QUEUE_PREFIX):
return None
return source
@router.get("/api/parse/queue")
async def parse_queue(page: int = 1, page_size: int = 20):
if page < 1 or page_size < 1 or page_size > 100:
return JSONResponse({"error": "invalid pagination"}, status_code=400)
with serving_lock():
total, sources = parse_queue_docs(PARSE_ARCHIVE_ID, PARSE_QUEUE_PREFIX, page, page_size)
return {
"archive_id": PARSE_ARCHIVE_ID,
"prefix": PARSE_QUEUE_PREFIX,
"total": total,
"page": page,
"page_size": page_size,
"items": [parse_core.queue_item(source) for source in sources],
}
@router.get("/api/parse/{doc_id}/ocr")
async def parse_ocr_page(doc_id: str, page: int = 1):
source = parse_queue_source(doc_id)
if not source:
return JSONResponse({"error": "parse item not found"}, status_code=404)
article = source.get("article") or {}
page_start = int(article.get("page_start") or 0)
page_end = int(article.get("page_end") or 0)
if page < page_start or page > page_end:
return JSONResponse({"error": "page is outside the source range"}, status_code=404)
publication_id = str(source.get("publication_id") or "")
if not re.fullmatch(r"[0-9a-f-]{36}", publication_id, re.IGNORECASE):
return JSONResponse({"error": "invalid publication id"}, status_code=500)
url = f"{PARSE_OCR_BASE_URL}/{quote(publication_id, safe='')}/{page}.json"
try:
response = await source_files.source_client.get(url, follow_redirects=True)
response.raise_for_status()
if len(response.content) > PARSE_OCR_MAX_BYTES:
return JSONResponse({"error": "OCR page is too large"}, status_code=502)
payload = response.json()
except Exception as exc:
return JSONResponse({"error": f"OCR page unavailable: {exc}"}, status_code=502)
if not isinstance(payload, dict) or not isinstance(payload.get("ocr_results"), list):
return JSONResponse({"error": "OCR page is invalid"}, status_code=502)
return JSONResponse({"publication_id": publication_id, "page": page, **payload}, headers={
"Cache-Control": "public, max-age=300",
})
@router.post("/api/parse", status_code=202)
async def submit_parse(body: ParseRequest, request: Request):
if not GITHUB_TOKEN:
return JSONResponse({"error": "parsing is not configured"}, status_code=503)
if len(json.dumps(body.model_dump(), ensure_ascii=False)) > PARSE_MAX_PAYLOAD_CHARACTERS:
return JSONResponse({"error": "parse payload is too large"}, status_code=413)
source = parse_queue_source(body.doc_id)
if not source:
return JSONResponse({"error": "parse item not found or already resolved"}, status_code=404)
current_hash = content_hash(source)
if body.base_content_hash and body.base_content_hash != current_hash:
return JSONResponse({"error": "parse item changed since preview", "content_hash": current_hash}, status_code=409)
articles, error = parse_core.validate_articles(body.articles, source, PARSE_QUEUE_PREFIX)
if error:
return JSONResponse({"error": error}, status_code=400)
payload: dict[str, Any] = {
"doc_id": body.doc_id,
"archive_id": source.get("archive_id"),
"publication_id": source.get("publication_id"),
"kind": "parse",
"body": {
"articles": articles,
"locator": {
"title": (source.get("article") or {}).get("title"),
"page_start": (source.get("article") or {}).get("page_start"),
"page_end": (source.get("article") or {}).get("page_end"),
},
"source_files": source.get("source_files") or [],
"description": body.description,
},
}
request_id = hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
payload["request_id"] = request_id
if duplicate := duplicate_parse_response(request_id):
return duplicate
if error := parse_rate_error(request):
with parse_dispatch_lock:
parse_dispatches.pop(request_id, None)
return error
try:
await dispatch_parse(payload)
except Exception as exc:
with parse_dispatch_lock:
parse_dispatches.pop(request_id, None)
release_parse_rate(request)
return JSONResponse({"error": str(exc)}, status_code=502)
with parse_dispatch_lock:
parse_dispatches[request_id] = {"created_at": time.time(), "status": "accepted"}
return {"ok": True, "status": "accepted", "request_id": request_id, "tasks": 1}
|