from fastapi import FastAPI, Form, HTTPException, Request, Response, BackgroundTasks from fastapi.staticfiles import StaticFiles from fastapi.responses import JSONResponse, FileResponse import httpx from bs4 import BeautifulSoup import asyncio import urllib3 import logging import json import os import re from cachetools import TTLCache # Caches for high concurrency month_year_cache = TTLCache(maxsize=1, ttl=3600) # 1 hour course_by_month_cache = TTLCache(maxsize=2000, ttl=2592000) # 30 days faculty_view_cache = TTLCache(maxsize=30000, ttl=2592000) # 30 days user_marks_cache = TTLCache(maxsize=10000, ttl=300) # 5 minutes # Suppress the insecure request warnings caused by verify=False urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Set up simple logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # File path for cache PERSISTENT_CACHE_FILE = os.path.join(os.path.dirname(__file__), "persistent_cache.json") def load_persistent_cache(): if os.path.exists(PERSISTENT_CACHE_FILE): try: with open(PERSISTENT_CACHE_FILE, "r", encoding="utf-8") as f: data = json.load(f) # Load faculty views fv_loaded = 0 for k, v in data.get("faculty_view", {}).items(): key = int(k) if k.isdigit() else k faculty_view_cache[key] = v fv_loaded += 1 # Load course by month cbm_loaded = 0 for k, v in data.get("course_by_month", {}).items(): course_by_month_cache[k] = v cbm_loaded += 1 logger.info(f"Loaded persistent cache: {fv_loaded} faculty views, {cbm_loaded} course-by-months") except Exception as e: logger.error(f"Failed to load persistent cache: {e}") def save_persistent_cache(): try: data = { "faculty_view": {str(k): v for k, v in faculty_view_cache.items()}, "course_by_month": {str(k): v for k, v in course_by_month_cache.items()} } with open(PERSISTENT_CACHE_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) logger.info(f"Saved persistent cache: {len(faculty_view_cache)} faculty views, {len(course_by_month_cache)} course-by-months") except Exception as e: logger.error(f"Failed to save persistent cache: {e}") # Initialize FastAPI App app = FastAPI(title="SIMATS Result Hub") @app.on_event("startup") async def startup_event(): load_persistent_cache() # Serve static files app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/") async def index(): return FileResponse('static/index.html') @app.get("/style.css") async def style(): return FileResponse('static/style.css') @app.get("/script.js") async def script(): return FileResponse('static/script.js') @app.get("/favicon.ico") async def favicon(): return Response(status_code=204) @app.get("/api/debug-cache") async def debug_cache(): # Convert cache to a dict for inspection return {str(k): v for k, v in user_marks_cache.items()} @app.post("/api/login") async def login(username: str = Form(...), password: str = Form(...)): url = "https://arms.sse.saveetha.com/Login.aspx" headers = { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", "accept-language": "en-US,en;q=0.5", "cache-control": "no-cache", "content-type": "application/x-www-form-urlencoded", "origin": "https://arms.sse.saveetha.com", "pragma": "no-cache", "referer": "https://arms.sse.saveetha.com/Login.aspx", "upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" } try: # We use httpx.AsyncClient with follow_redirects=False for the POST # and verify=False to ignore SSL warnings. async with httpx.AsyncClient(verify=False, headers=headers) as client: # Step 1: GET request response_get = await client.get(url, timeout=15.0) response_get.raise_for_status() # Parse HTML soup = BeautifulSoup(response_get.text, 'html.parser') viewstate = soup.find(id="__VIEWSTATE") viewstategenerator = soup.find(id="__VIEWSTATEGENERATOR") eventvalidation = soup.find(id="__EVENTVALIDATION") if not viewstate or not eventvalidation: return JSONResponse(status_code=500, content={"detail": "Could not find ASP.NET hidden fields on the login page."}) payload = { "__VIEWSTATE": viewstate.get("value", ""), "__VIEWSTATEGENERATOR": viewstategenerator.get("value", "") if viewstategenerator else "", "__EVENTVALIDATION": eventvalidation.get("value", ""), "txtusername": username, "txtpassword": password, "btnlogin": "Login" } # Use the cookies from the GET request cookies = response_get.cookies # Step 2: POST request response_post = await client.post(url, data=payload, cookies=cookies, follow_redirects=False, timeout=15.0) # Extract the ASP.NET_SessionId cookie from the POST response or the client's current cookie jar session_id = client.cookies.get("ASP.NET_SessionId", domain="arms.sse.saveetha.com") or response_post.cookies.get("ASP.NET_SessionId") if response_post.status_code == 302: return JSONResponse(content={"success": True, "ASP.NET_SessionId": session_id, "message": "Login successful!"}) if response_post.status_code == 200: if "Invalid Username" in response_post.text or "Invalid" in response_post.text: return JSONResponse(status_code=401, content={"detail": "Invalid Username or Password"}) return JSONResponse(content={"success": True, "ASP.NET_SessionId": session_id, "message": "Login appears successful!"}) return JSONResponse(status_code=401, content={"detail": "Login failed or unexpected status code."}) except httpx.RequestError as e: return JSONResponse(status_code=502, content={"detail": f"API request failed: {str(e)}"}) except Exception as e: return JSONResponse(status_code=500, content={"detail": f"An error occurred: {str(e)}"}) async def fetch_api3(client: httpx.AsyncClient, month_id: str): """Fetch subjects for a given month.""" if month_id in course_by_month_cache: return month_id, course_by_month_cache[month_id] url = f"https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=CoursebyMonth&Mode=PublishCoursebyMonthNew&Monthyear={month_id}" resp = await client.get(url, timeout=15.0) data = resp.json().get("Table", []) course_by_month_cache[month_id] = data return month_id, data async def process_course(client: httpx.AsyncClient, course_info: dict, subject_ids: list, username: str): """Fetch marks for a specific course/subject_ids.""" try: user_clean = str(username).strip().lower() for subject_id in subject_ids: # API 4 (Cached) if subject_id in faculty_view_cache: api4_data = faculty_view_cache[subject_id] else: api4_url = f"https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=ResultView&Mode=NewResultViewFaculty&Coursename={subject_id}" resp4 = await client.get(api4_url, timeout=15.0) api4_data = resp4.json().get("Table", []) faculty_view_cache[subject_id] = api4_data view_id = None for student in api4_data: reg_clean = str(student.get("RegNo", "")).strip().lower() # Alias check for SSETSCS054 -> SSETSSH239 target_users = [user_clean] if user_clean == "ssetscs054": target_users.append("ssetssh239") match_found = False for target in target_users: if reg_clean == target or reg_clean.startswith(target) or target.startswith(reg_clean): match_found = True break if match_found: view_id = student.get("ViewId") break if view_id: # API 5 api5_url = f"https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=ViewMarks&Mode=MarkSplitbyId&Id={subject_id}&Id2={view_id}" resp5 = await client.get(api5_url, timeout=15.0) api5_data = resp5.json().get("Table", []) return {"course": course_info, "marks": api5_data, "error": None} return {"course": course_info, "marks": None, "error": f"View ID not found (searched across {len(subject_ids)} subject sections)"} except Exception as e: return {"course": course_info, "marks": None, "error": str(e)} @app.post("/api/fetch-marks") async def fetch_marks(username: str = Form(...), session_id: str = Form(...)): cache_key = f"{username}_{session_id}" if cache_key in user_marks_cache: return JSONResponse(content={"success": True, "data": user_marks_cache[cache_key]}) headers = { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } cookies = {"ASP.NET_SessionId": session_id} try: # Increase connection limits for high concurrency limits = httpx.Limits(max_connections=2000, max_keepalive_connections=500) # Use a single httpx AsyncClient session for all requests async with httpx.AsyncClient(verify=False, headers=headers, cookies=cookies, limits=limits) as client: # API 1 api1_url = "https://arms.sse.saveetha.com/Handler/Student.ashx?Page=CourseEnroll&Mode=GetResult&Id=0" resp1 = await client.get(api1_url, timeout=15.0) courses = resp1.json().get("Table", []) # API 2 (Cached) if "global" in month_year_cache: month_years = month_year_cache["global"] else: api2_url = "https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=MonthYear&Mode=MonthYearNew" resp2 = await client.get(api2_url, timeout=15.0) month_years = resp2.json().get("Table", []) month_year_cache["global"] = month_years month_map = {item["Value"]: item["Id"] for item in month_years} unique_months = set() for c in courses: mv = c.get("MonthYearValue") if mv in month_map: unique_months.add(month_map[mv]) # Fetch API 3 for all unique months concurrently api3_tasks = [fetch_api3(client, mid) for mid in unique_months] api3_results = await asyncio.gather(*api3_tasks, return_exceptions=True) api3_cache = {} for res in api3_results: if isinstance(res, tuple): mid, data = res api3_cache[mid] = data else: logger.error(f"Error fetching API 3: {res}") # Build course to subject mapping course_subject_map = [] for c in courses: mv = c.get("MonthYearValue") if mv not in month_map: continue month_id = month_map[mv] subject_ids = [] for sub in api3_cache.get(month_id, []): if sub["SubjectCode"] == c["CourseCode"]: subject_ids.append(sub["SubjectId"]) if subject_ids: course_subject_map.append((c, subject_ids)) # Fetch all marks concurrently marks_tasks = [process_course(client, c, sids, username) for c, sids in course_subject_map] final_data = await asyncio.gather(*marks_tasks, return_exceptions=True) # Filter out exceptions from gather and ensure valid dict structure processed_data = [] for item in final_data: if isinstance(item, Exception): logger.error(f"Error processing course: {item}") continue processed_data.append(item) # Sort data by Course Name for consistency processed_data.sort(key=lambda x: x["course"].get("CourseName", "")) user_marks_cache[cache_key] = processed_data return JSONResponse(content={"success": True, "data": processed_data}) except Exception as e: logger.exception("Failed to fetch detailed marks") error_msg = str(e) if "Expecting value" in error_msg: return JSONResponse(status_code=401, content={"detail": "Your credentials are invalid"}) return JSONResponse(status_code=500, content={"detail": f"Failed to fetch detailed marks: {error_msg}"}) # Cache for search results per register number (avoids re-searching) search_result_cache = TTLCache(maxsize=5000, ttl=600) # 10 minutes @app.post("/api/search-student") async def search_student(register_no: str = Form(...), session_id: str = Form(...), background_tasks: BackgroundTasks = None): """Search for a student's results by register number using regulation and month filtering.""" import time as _time register_clean = register_no.strip().lower() # ===== FAST PATH: Return cached search result instantly ===== if register_clean in search_result_cache: logger.info(f"Search cache HIT for {register_clean}") return JSONResponse(content={"success": True, "data": search_result_cache[register_clean]}) headers = { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } cookies = {"ASP.NET_SessionId": session_id} TOTAL_TIME_BUDGET = 55 # seconds — stop Phase 2 scanning after this start_time = _time.monotonic() try: limits = httpx.Limits(max_connections=500, max_keepalive_connections=200) async with httpx.AsyncClient(verify=False, headers=headers, cookies=cookies, limits=limits) as client: # Step 1: Get all month/years (cached, instant) if "global" in month_year_cache: month_years = month_year_cache["global"] else: api2_url = "https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=MonthYear&Mode=MonthYearNew" resp2 = await client.get(api2_url, timeout=15.0) month_years = resp2.json().get("Table", []) month_year_cache["global"] = month_years # Extract student admission year admission_year = 2015 try: digits = "".join(c for c in register_clean if c.isdigit()) if len(digits) >= 6: y1 = int(digits[4:6]) if 15 <= y1 <= 26: admission_year = 2000 + y1 elif len(digits) >= 2: y2 = int(digits[:2]) if 15 <= y2 <= 26: admission_year = 2000 + y2 except Exception: pass logger.info(f"Student inferred admission year: {admission_year}") # Filter months (only search years >= admission_year - 1 and <= admission_year + 6) filtered_month_ids = [] for item in month_years: mid = item.get("Id") val = item.get("Value", "") if not mid: continue try: m = re.search(r'\b(20\d{2})\b', val) if m: year = int(m.group(1)) if admission_year - 1 <= year <= admission_year + 6: filtered_month_ids.append(mid) else: filtered_month_ids.append(mid) except Exception: filtered_month_ids.append(mid) logger.info(f"Filtered to {len(filtered_month_ids)} out of {len(month_years)} months (year range: {admission_year-1} - {admission_year+6})") # Step 2: Fetch subjects for filtered months api3_tasks = [fetch_api3(client, mid) for mid in filtered_month_ids] api3_results = await asyncio.gather(*api3_tasks, return_exceptions=True) # Build subject map and filter by regulation year subject_map = {} for res in api3_results: if isinstance(res, tuple): _, subjects = res for sub in subjects: sid = sub.get("SubjectId") scode = sub.get("SubjectCode", "") if sid: # Filter out subjects that belong to regulations in the future is_valid = True try: sm = re.match(r'^\s*(\d{2})', scode) if sm: sub_year = 2000 + int(sm.group(1)) if sub_year > admission_year: is_valid = False except Exception: pass if is_valid: subject_map[sid] = sub if not subject_map: return JSONResponse(content={"success": True, "data": []}) logger.info(f"Scanning {len(subject_map)} regulation-matched subjects for {register_clean}") # ===== Phase 1: Scan cached faculty_view data (no network) ===== cached_subject_ids = set() found_hits = [] # (subject_id, student_record) uncached_subject_ids = [] for sid in subject_map: if sid in faculty_view_cache: cached_subject_ids.add(sid) for student in faculty_view_cache[sid]: reg = str(student.get("RegNo", "")).strip().lower() target_users = [register_clean] if register_clean == "ssetscs054": target_users.append("ssetssh239") match_found = False for target in target_users: if reg == target or reg.startswith(target) or target.startswith(reg): match_found = True break if match_found: found_hits.append((sid, student)) break else: uncached_subject_ids.append(sid) logger.info(f"Phase 1 cache: {len(cached_subject_ids)} cached, {len(found_hits)} hits found, {len(uncached_subject_ids)} uncached") # ===== Phase 2: Fetch uncached subjects concurrently with time limit ===== semaphore = asyncio.Semaphore(250) # Increased concurrency to maximize throughput async def check_uncached_subject(subject_id): async with semaphore: try: api4_url = f"https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=ResultView&Mode=NewResultViewFaculty&Coursename={subject_id}" resp4 = await client.get(api4_url, timeout=10.0) api4_data = resp4.json().get("Table", []) faculty_view_cache[subject_id] = api4_data for student in api4_data: reg = str(student.get("RegNo", "")).strip().lower() target_users = [register_clean] if register_clean == "ssetscs054": target_users.append("ssetssh239") for target in target_users: if reg == target or reg.startswith(target) or target.startswith(reg): return (subject_id, student) return None except Exception as e: return None if uncached_subject_ids: total_uncached = len(uncached_subject_ids) phase2_tasks = [asyncio.create_task(check_uncached_subject(sid)) for sid in uncached_subject_ids] elapsed = _time.monotonic() - start_time remaining_budget = max(TOTAL_TIME_BUDGET - elapsed, 5.0) try: # Run all tasks concurrently, bound by semaphore and global timeout done, pending = await asyncio.wait( phase2_tasks, timeout=remaining_budget, return_when=asyncio.ALL_COMPLETED ) for task in done: try: r = task.result() if r: found_hits.append(r) except Exception as e: logger.error(f"Error in Phase 2 task result: {e}") if pending: logger.warning(f"Phase 2 timeout: {len(pending)}/{total_uncached} subjects not scanned.") for p in pending: p.cancel() logger.info(f"Phase 2 complete: scanned {len(done)}/{total_uncached} uncached, elapsed {_time.monotonic() - start_time:.1f}s") except Exception as e: logger.error(f"Phase 2 wait error: {e}") logger.info(f"Total hits found: {len(found_hits)}") if not found_hits: search_result_cache[register_clean] = [] return JSONResponse(content={"success": True, "data": []}) # ===== Phase 3: Fetch detailed marks for all hits in parallel ===== async def fetch_mark_details(subject_id, student): view_id = student.get("ViewId") if not view_id: # Return course info even without marks sub = subject_map.get(subject_id, {}) return { "course": { "CourseCode": sub.get("SubjectCode", ""), "CourseName": sub.get("SubjectName", sub.get("SubjectCode", "Unknown")), "MonthYearValue": sub.get("MonthYearValue", ""), "FinalGrade": student.get("Grade", ""), "Credit": student.get("Credit", 1) }, "marks": [], "error": None } try: async with semaphore: api5_url = f"https://arms.sse.saveetha.com/Handler/Controller.ashx?Page=ViewMarks&Mode=MarkSplitbyId&Id={subject_id}&Id2={view_id}" resp5 = await client.get(api5_url, timeout=10.0) api5_data = resp5.json().get("Table", []) sub = subject_map.get(subject_id, {}) course_info = { "CourseCode": sub.get("SubjectCode", ""), "CourseName": sub.get("SubjectName", sub.get("SubjectCode", "Unknown")), "MonthYearValue": sub.get("MonthYearValue", ""), "FinalGrade": student.get("Grade", ""), "Credit": student.get("Credit", 1) } return {"course": course_info, "marks": api5_data, "error": None} except Exception as e: logger.error(f"Error fetching marks for {subject_id}: {e}") # Return course info even if marks fail sub = subject_map.get(subject_id, {}) return { "course": { "CourseCode": sub.get("SubjectCode", ""), "CourseName": sub.get("SubjectName", sub.get("SubjectCode", "Unknown")), "MonthYearValue": sub.get("MonthYearValue", ""), "FinalGrade": student.get("Grade", ""), "Credit": student.get("Credit", 1) }, "marks": [], "error": None } # Phase 3 also gets a time limit try: marks_tasks = [fetch_mark_details(sid, stu) for sid, stu in found_hits] marks_results = await asyncio.wait_for( asyncio.gather(*marks_tasks, return_exceptions=True), timeout=30.0 ) except asyncio.TimeoutError: logger.warning("Phase 3 (marks fetch) timed out after 30s") marks_results = [] processed_data = [] for r in marks_results: if r and not isinstance(r, Exception): processed_data.append(r) # If marks fetch timed out but we have hits, still return course info if not processed_data and found_hits: for sid, stu in found_hits: sub = subject_map.get(sid, {}) processed_data.append({ "course": { "CourseCode": sub.get("SubjectCode", ""), "CourseName": sub.get("SubjectName", sub.get("SubjectCode", "Unknown")), "MonthYearValue": sub.get("MonthYearValue", ""), "FinalGrade": stu.get("Grade", ""), "Credit": stu.get("Credit", 1) }, "marks": [], "error": None }) processed_data.sort(key=lambda x: x["course"].get("CourseName", "")) # Cache the result for this student search_result_cache[register_clean] = processed_data # Save cache updates to disk in background if background_tasks: background_tasks.add_task(save_persistent_cache) total_elapsed = _time.monotonic() - start_time logger.info(f"Search complete for {register_clean}: {len(processed_data)} courses in {total_elapsed:.1f}s") return JSONResponse(content={"success": True, "data": processed_data}) except Exception as e: logger.exception("Failed to search student") error_msg = str(e) if "Expecting value" in error_msg: return JSONResponse(status_code=401, content={"detail": "Session expired. Please log in again."}) return JSONResponse(status_code=500, content={"detail": f"Search failed: {error_msg}"}) if __name__ == "__main__": import uvicorn # Enable running via `python main.py` directly for convenience uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)