ARMS-Result / main.py
sidhesh01's picture
Update main.py
b48e484 verified
Raw History Blame Contribute Delete
29.4 kB
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)