ktu-papers-api / server.py
GitHub Action
Deploy clean API instance with LFS and README metadata
7f6b634
Raw
History Blame Contribute Delete
6.3 kB
"""
server.py
---------
FastAPI REST API exposing KTU paper search + branded download + live status pinging.
This is the layer you call from your website frontend OR from another AI / agent.
"""
from __future__ import annotations
import io
import os
import time # <--- Added for tracking response latency in the status check
import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import ktu_repo as repo
import watermark as wm
app = FastAPI(title="KTU Papers API", version="1.0")
# allow your website's domain to call this API from the browser
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ---- branding config ----
BRAND_TEXT = os.getenv("BRAND_TEXT", "Downloaded from Keralatimetable.in")
LOGO_PATH = os.getenv("BRAND_LOGO_PATH", "keralattlogo.png")
LOGO_BYTES = open(LOGO_PATH, "rb").read() if LOGO_PATH and os.path.exists(LOGO_PATH) else None
ADMIN_KEY = os.getenv("ADMIN_KEY", "change-me")
# ---- Hugging Face Vault URL ----
DATASET_RAW_URL = "https://huggingface.co/datasets/KeralaTimetable/ktu-pyq-archive/resolve/main"
class DownloadReq(BaseModel):
handles: list[str]
watermark: bool = True
@app.get("/search")
def search(q: str = Query(..., min_length=2), year: int | None = None, limit: int = 50):
"""Search papers by course code or keyword with exact-match priority & deduplication."""
# 1. Fetch extra results so our exact matches aren't buried under partial matches
raw_results = repo.search(q, year=year, limit=limit + 100)
q_clean = q.upper().replace(" ", "")
exact_matches = []
for r in raw_results:
cc = r.get("course_code") or ""
if cc.upper().replace(" ", "") == q_clean:
exact_matches.append(r)
# 2. Smart Filter: If we found exact course code matches, ONLY show those.
# Otherwise, fall back to normal search (for keyword searches like "Physics")
if exact_matches:
filtered_results = exact_matches
else:
filtered_results = raw_results
# 3. Deduplicate: Remove identical entries if KTU uploaded the same file twice
unique_results = []
seen_handles = set()
for r in filtered_results:
handle = r.get("handle")
if handle not in seen_handles:
seen_handles.add(handle)
unique_results.append(r)
# 4. Apply the final limit
final_results = unique_results[:limit]
return {"query": q, "year": year, "count": len(final_results), "results": final_results}
@app.get("/paper/{prefix}/{num}")
def paper(prefix: str, num: str):
"""Get one paper's details + resolve its direct PDF url."""
handle = f"{prefix}/{num}"
rows = repo.search(handle, limit=1)
pdf_url = repo.resolve_pdf_url(handle)
if not pdf_url:
raise HTTPException(404, "paper not found or no PDF")
return {"handle": handle, "pdf_url": pdf_url}
@app.get("/live-ping")
async def live_ping(url: str):
"""
🆕 Bypasses browser CORS restrictions to run an completely transparent backend ping test to KTU.
Accurately isolates genuine 502/504 errors and SSL handshake blockages.
"""
headers = {
"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",
}
start_time = time.time()
try:
# Using a strict 6-second max response time for live validation
async with httpx.AsyncClient(timeout=6.0, verify=False) as client:
response = await client.get(url, headers=headers)
ping_ms = int((time.time() - start_time) * 1000)
if response.status_code < 400:
return {"status": "Online", "ping": ping_ms, "detail": "Operational"}
else:
return {"status": "Offline", "ping": "Error", "detail": f"HTTP {response.status_code}"}
except httpx.ConnectTimeout:
return {"status": "Offline", "ping": "Timeout", "detail": "Connection Timeout"}
except httpx.ConnectError as e:
# Check if the connection dropped cleanly due to local SSL verification crashes
if "SSL" in str(e) or "certificate" in str(e):
return {"status": "SSL Issue", "ping": "Blocked", "detail": "SSL Error"}
return {"status": "Offline", "ping": "Error", "detail": "Network Error"}
except Exception:
return {"status": "Offline", "ping": "Error", "detail": "Down"}
@app.post("/download")
async def download(req: DownloadReq):
"""Download from Hugging Face + (optionally) merge + watermark the requested papers."""
if not req.handles:
raise HTTPException(400, "no handles provided")
pdfs: list[bytes] = []
# follow_redirects=True allows httpx to follow Hugging Face CDN routing
async with httpx.AsyncClient(follow_redirects=True) as client:
for h in req.handles:
# Convert JEC '1/9757' into Hugging Face '1_9757.pdf'
safe_filename = f"{h.replace('/', '_')}.pdf"
file_url = f"{DATASET_RAW_URL}/{safe_filename}"
response = await client.get(file_url)
if response.status_code != 200:
print(f"FAILED: {file_url} returned status {response.status_code}")
raise HTTPException(404, f"no PDF for handle {h} in cloud archive")
pdfs.append(response.content)
# Your custom watermark.py processes the bytes here
if req.watermark:
out = wm.brand_papers(pdfs, logo_bytes=LOGO_BYTES, text=BRAND_TEXT)
else:
out = wm.merge_pdfs(pdfs) if len(pdfs) > 1 else pdfs[0]
fname = "ktu_papers.pdf"
return StreamingResponse(io.BytesIO(out), media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@app.post("/admin/harvest")
def admin_harvest(key: str):
"""Re-harvest the repository index. Protect with ADMIN_KEY env var."""
if key != ADMIN_KEY:
raise HTTPException(403, "forbidden")
n = repo.harvest()
return {"stored": n}