#!/usr/bin/env python3 import os import json import base64 import hashlib from pathlib import Path from typing import Dict, Optional from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel # Import exact helpers from capture_passive_playwright_async from scrapers.capture_passive_playwright_async import ( sanitize, guess_ext, now_ts, try_pretty_json, save_binary, save_text ) app = FastAPI(title="STATdx Central Cache Server") # Allow CORS for options requests and standard requests app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) OUTPUT_DIR = Path("xhr_captured_async") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) INDEX_PATH = OUTPUT_DIR / "capture_index.jsonl" # Persistent in-memory index in_memory_index: Dict[str, list] = {} def load_existing_index(): if INDEX_PATH.exists(): print(f"Loading existing index from {INDEX_PATH}...") try: with open(INDEX_PATH, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) ch = entry.get("content_hash") if ch: in_memory_index.setdefault(ch, []).append(entry) except Exception: pass print(f"Loaded {len(in_memory_index)} unique hashes into memory.") except Exception as e: print(f"Error loading index: {e}") # Load index at import/startup load_existing_index() class HashCheckRequest(BaseModel): content_hash: str class CapturePayload(BaseModel): url: str resource_type: str status: int timestamp: Optional[str] = None headers: Dict[str, str] body_base64: str content_type: str @app.post("/api/check-hash") async def check_hash(payload: HashCheckRequest): exists = payload.content_hash in in_memory_index return {"exists": exists} @app.post("/api/capture") async def capture(payload: CapturePayload): try: # Decode body body_bytes = base64.b64decode(payload.body_base64) content_hash = hashlib.sha256(body_bytes).hexdigest() # Check deduplication if content_hash in in_memory_index: # We already have this exact content saved # Just return and do not write duplicate files return {"status": "skipped", "reason": "content_hash already cached"} url = payload.url ts = payload.timestamp or now_ts() safe = sanitize(url.replace('https://', '').replace('http://', '')) h = hashlib.sha1((url + ts).encode('utf-8')).hexdigest()[:8] body_path = None excerpt = None ctype = payload.content_type.lower() rtype = payload.resource_type.lower() # Handle text content if 'application/json' in ctype or ctype.startswith('text/') or 'javascript' in ctype: try: txt = body_bytes.decode('utf-8') except Exception: txt = body_bytes.decode('latin-1', errors='replace') pretty = try_pretty_json(txt) if pretty is not None: use_text = pretty excerpt = pretty[:800] ext = 'json' else: use_text = txt excerpt = txt[:800] ext = 'txt' content_sha1 = hashlib.sha1(use_text.encode('utf-8')).hexdigest() fname = f"{safe}_{content_sha1}.{ext}" body_path = str(OUTPUT_DIR / fname) if not os.path.exists(body_path): save_text(OUTPUT_DIR / fname, use_text) else: # Handle binary content ext = guess_ext(ctype) content_sha1 = hashlib.sha1(body_bytes).hexdigest() fname = f"{safe}_{content_sha1}.{ext}" if ctype.startswith('image/'): subdir = OUTPUT_DIR / 'images' subdir.mkdir(parents=True, exist_ok=True) body_path = str(subdir / fname) if not os.path.exists(body_path): save_binary(subdir / fname, body_bytes) else: subdir = OUTPUT_DIR / 'assets' subdir.mkdir(parents=True, exist_ok=True) body_path = str(subdir / fname) if not os.path.exists(body_path): save_binary(subdir / fname, body_bytes) # Meta file creation meta_base = content_hash or h new_meta_name = f"{safe}_{meta_base}.meta.json" new_meta_path = OUTPUT_DIR / new_meta_name meta = { 'url': url, 'resource_type': rtype, 'status': payload.status, 'timestamp': ts, 'response_headers': payload.headers, 'response_body_file': body_path, 'response_excerpt': excerpt, 'content_hash': content_hash, } save_text(new_meta_path, json.dumps(meta, ensure_ascii=False, indent=2)) # Write to log index entry = { 'url': url, 'resource_type': rtype, 'timestamp': ts, 'body_file': body_path, 'meta_file': str(new_meta_path), 'excerpt': excerpt, 'content_hash': content_hash } with open(INDEX_PATH, 'a', encoding='utf-8') as idx: idx.write(json.dumps(entry, ensure_ascii=False) + '\n') # Update in-memory index in_memory_index.setdefault(content_hash, []).append(entry) print(f"SAVED: {url} -> {body_path}") return {"status": "saved", "path": body_path} except Exception as e: import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) if __name__ == "__main__": import uvicorn uvicorn.run("scrapers.cache_server:app", host="0.0.0.0", port=8000, reload=True)