Spaces:
Sleeping
Sleeping
File size: 21,535 Bytes
3301039 0dab250 3301039 3498507 3301039 0dab250 3301039 3c4c9fe 3301039 3c4c9fe 3301039 0dab250 3301039 3498507 3301039 0dab250 3301039 0dab250 3301039 8c96bf4 3301039 8c96bf4 3301039 0dab250 3301039 0dab250 3301039 0dab250 3301039 80fc367 3301039 0dab250 3301039 8c96bf4 0dab250 8c96bf4 3301039 3498507 80fc367 3301039 0dab250 3301039 0dab250 3301039 8c96bf4 0dab250 8c96bf4 3301039 3498507 80fc367 3301039 0dab250 3301039 0dab250 3301039 0dab250 | 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 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 | # scraper.py (v2 — Lightweight requests + ddddocr captcha solver)
import time
import re
import io
import random
import warnings
import threading
import concurrent.futures
import requests
import urllib3
from PIL import Image, ImageFilter
import numpy as np
from bs4 import BeautifulSoup
import ddddocr
from db import get_cached_result, save_cached_result, init_db
import sys
import builtins
def force_print(*args, **kwargs):
kwargs['file'] = sys.stderr
kwargs['flush'] = True
builtins.print(*args, **kwargs)
print = force_print
# Initialize database
init_db()
# VTU's SSL cert is misconfigured — suppress the warning
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# --- Initialize the captcha solver (once, globally) ---
ocr = ddddocr.DdddOcr(beta=True, show_ad=False)
# --- Browser-like headers to avoid being flagged ---
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
'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.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
class SessionPool:
"""Manages a pool of independent requests.Session objects to prevent VTU PHPSESSID collisions."""
def __init__(self, size=5):
self.sessions = [requests.Session() for _ in range(size)]
self.lock = threading.Lock()
self.index = 0
def get_session(self):
with self.lock:
session = self.sessions[self.index]
self.index = (self.index + 1) % len(self.sessions)
return session
class CaptchaEvolver:
"""
Manages and scores different image preprocessing strategies.
Promotes successful strategies to the top to adapt to VTU's changing captchas.
"""
def __init__(self):
# Define strategies: (filter_size, threshold)
self.strategies = [
{'name': 'raw', 'filter': None, 'thresh': None}, # Strategy 0: Raw image
{'name': 'med3_thresh140', 'filter': 3, 'thresh': 140},
{'name': 'med3_thresh160', 'filter': 3, 'thresh': 160},
{'name': 'med5_thresh130', 'filter': 5, 'thresh': 130},
{'name': 'thresh_only150', 'filter': None, 'thresh': 150}
]
# Track success rates: {strategy_name: success_count}
self.success_counts = {s['name']: 0 for s in self.strategies}
self.current_order = list(self.strategies) # Initially in defined order
def report_success(self, strategy_name):
self.success_counts[strategy_name] += 1
# Re-sort current_order based on success count (highest first)
self.current_order.sort(key=lambda s: self.success_counts[s['name']], reverse=True)
# print(f"[EVOLVER] Promoted strategy '{strategy_name}'. Current order: {[s['name'] for s in self.current_order]}")
def apply_strategy(self, image_bytes, strategy):
if strategy['name'] == 'raw':
return image_bytes
try:
img = Image.open(io.BytesIO(image_bytes)).convert('L')
if strategy['filter']:
img = img.filter(ImageFilter.MedianFilter(size=strategy['filter']))
img_array = np.array(img)
if strategy['thresh']:
img_array = np.where(img_array < strategy['thresh'], 0, 255).astype(np.uint8)
clean_img = Image.fromarray(img_array)
output = io.BytesIO()
clean_img.save(output, format='PNG')
return output.getvalue()
except Exception as e:
# print(f"[WARN] Strategy {strategy['name']} failed: {e}")
return image_bytes
# Global evolver instance
evolver = CaptchaEvolver()
def solve_captcha(image_bytes):
"""
Solve a captcha image using ddddocr with adaptive preprocessing.
Returns (solution, strategy_used) or (None, None).
"""
for strategy in evolver.current_order:
try:
processed_bytes = evolver.apply_strategy(image_bytes, strategy)
result = ocr.classification(processed_bytes)
cleaned = re.sub(r'[^a-zA-Z0-9]', '', result)
if cleaned and len(cleaned) == 6:
print(f"[ddddocr] [{strategy['name']}]: '{result}' -> Cleaned: '{cleaned}'")
return cleaned, strategy['name']
except Exception as e:
print(f"❌ Error applying strategy {strategy['name']}: {e}")
continue
print(f"[WARN] Captcha solver exhausted all {len(evolver.strategies)} strategies. Failed.")
return None, None
def parse_result_html(html_content, usn):
"""
Parses VTU result HTML page using a robust Header-Anchored approach.
"""
soup = BeautifulSoup(html_content, 'html.parser')
# --- Handle invalid or unavailable results ---
if re.search(r'Invalid USN|Results are not yet available', html_content, re.I):
print(f"[{usn}] Invalid USN or results not available.")
return None, "Invalid USN or Results not available"
try:
# --- Flexible extraction for USN and Name ---
usn_value = None
name_value = None
# We look for text nodes containing "University Seat Number" or "USN"
for tag in soup.find_all(string=re.compile(r'University Seat Number|USN', re.I)):
container = tag.find_parent(['td', 'div'])
if container:
next_node = container.find_next_sibling(['td', 'div'])
if next_node:
usn_value = re.sub(r'[:\s]+', '', next_node.get_text(strip=True)).upper()
break
for tag in soup.find_all(string=re.compile(r'Student Name', re.I)):
container = tag.find_parent(['td', 'div'])
if container:
next_node = container.find_next_sibling(['td', 'div'])
if next_node:
name_value = re.sub(r'^[:\s]+', '', next_node.get_text(strip=True)).title()
break
if not usn_value or not name_value:
if not usn_value: usn_value = usn # Fallback
if not name_value: name_value = "Unknown Name"
# --- Extract Semester ---
semester_value = None
for tag in soup.find_all(string=re.compile(r'Semester\s*:\s*\d+', re.I)):
match = re.search(r'Semester\s*:\s*(\d+)', tag, re.I)
if match:
semester_value = int(match.group(1))
break
# --- Header-Anchored Parsing for Subjects ---
subjects = []
# 1. Find the container (table or div) that has the headers
results_container = None
header_row = None
for container in soup.find_all(['div', 'table']):
if 'Subject Code' in container.get_text():
if container.name == 'table':
header_row = container.find(lambda tag: tag.name == 'tr' and 'Subject Code' in tag.get_text())
if header_row:
results_container = container
break
elif container.name == 'div' and 'divTable' in container.get('class', []):
header_row = container.find(lambda tag: tag.name == 'div' and 'Subject Code' in tag.get_text() and ('TableRow' in ''.join(tag.get('class', [])) or 'TableRow' in str(tag.get('class', []))))
if header_row:
results_container = container
break
if not results_container or not header_row:
with open(f"failed_subjects_{usn}.html", "w", encoding="utf-8") as f:
f.write(html_content)
print(f"[{usn}] Result table or header row not found. Saved HTML to failed_subjects_{usn}.html")
return None, "Result table or header row not found"
# Find column indices
col_indices = {
'subject_code': 0,
'internal_marks': 2,
'external_marks': 3,
'total': 4,
'result': 5,
'grade_point': -1,
'credits': -1
}
if header_row.name == 'tr':
headers = header_row.find_all(['th', 'td'])
else:
headers = header_row.find_all('div', recursive=False)
if not headers:
headers = header_row.find_all('div', class_='divTableCell')
header_texts = [h.get_text(strip=True).lower() for h in headers]
for idx, text in enumerate(header_texts):
if 'subject code' in text: col_indices['subject_code'] = idx
elif 'internal' in text or 'ia' in text or 'ind' in text: col_indices['internal_marks'] = idx
elif 'external' in text or 'ea' in text or 'uni' in text: col_indices['external_marks'] = idx
elif 'total' in text: col_indices['total'] = idx
elif 'result' in text: col_indices['result'] = idx
elif 'grade point' in text or 'gp' in text.split(): col_indices['grade_point'] = idx
elif 'credit' in text: col_indices['credits'] = idx
# 2. Extract Data Rows by looking at siblings of the header row
current_row = header_row.find_next_sibling()
while current_row:
if current_row.name == 'tr':
cells = current_row.find_all('td')
else:
# For div layout, grab direct div children or fallback to divTableCell
cells = [c for c in current_row.find_all('div', recursive=False)]
if not cells:
cells = current_row.find_all('div', class_='divTableCell')
if len(cells) > max(col_indices['subject_code'], col_indices['result']):
subject_data = {
'subject_code': cells[col_indices['subject_code']].get_text(strip=True),
'internal_marks': cells[col_indices['internal_marks']].get_text(strip=True) if col_indices['internal_marks'] >= 0 else '0',
'external_marks': cells[col_indices['external_marks']].get_text(strip=True) if col_indices['external_marks'] >= 0 else '0',
'total': cells[col_indices['total']].get_text(strip=True) if col_indices['total'] >= 0 else '0',
'result': cells[col_indices['result']].get_text(strip=True) if col_indices['result'] >= 0 else ''
}
if col_indices['grade_point'] >= 0 and len(cells) > col_indices['grade_point']:
subject_data['grade_point'] = cells[col_indices['grade_point']].get_text(strip=True)
if col_indices['credits'] >= 0 and len(cells) > col_indices['credits']:
subject_data['credits'] = cells[col_indices['credits']].get_text(strip=True)
subjects.append(subject_data)
current_row = current_row.find_next_sibling()
if not subjects:
with open(f"failed_subjects_{usn}.html", "w", encoding="utf-8") as f:
f.write(html_content)
print(f"[{usn}] No subjects found for {name_value}. Saved HTML to failed_subjects_{usn}.html")
return None, "No subjects found in results table"
student_result = {
'usn': usn_value,
'student_name': name_value,
'subjects': subjects
}
if semester_value is not None:
student_result['semester'] = semester_value
print(f"✅ Parsed: {name_value} ({usn_value}) — {len(subjects)} subjects.")
return student_result, None
except Exception as e:
print(f"[{usn}] Error during parsing: {e}")
return None, f"Parsing error: {str(e)}"
def fetch_single_result(session, vtu_url, usn, max_attempts=25, job_state=None):
"""
Fetch a single USN result using HTTP requests + ddddocr captcha solving.
Returns parsed result dict or None.
"""
# Determine the base URL for the captcha image
# VTU captcha is usually at the same directory level as the results page
base_url = vtu_url.rsplit('/', 1)[0]
captcha_url = f"{base_url}/captcha/vtu_captcha.php"
for attempt in range(max_attempts):
try:
# Add micro-jitter delay to offset concurrent requests
if attempt > 0:
time.sleep(random.uniform(0.5, 1.5))
else:
time.sleep(random.uniform(0.1, 0.5))
print(f"\n[FETCH] {usn} (Attempt {attempt + 1}/{max_attempts})...")
if job_state:
job_state['progress'] = f"Solving captcha for {usn} (Attempt {attempt + 1}/{max_attempts})..."
# Step 1: GET the results page to establish session
page_response = session.get(vtu_url, headers=HEADERS, timeout=15, verify=False)
page_response.raise_for_status()
# Step 2: Find the captcha image URL and hidden Token from the page
soup = BeautifulSoup(page_response.text, 'html.parser')
token_input = soup.find('input', {'name': 'Token'})
token = token_input.get('value', '') if token_input else ''
captcha_img = soup.find('img', src=re.compile(r'captcha', re.I))
if captcha_img:
captcha_src = captcha_img.get('src', '')
# Handle relative URLs
if captcha_src.startswith('http'):
actual_captcha_url = captcha_src
elif captcha_src.startswith('/'):
# Absolute path from domain root
from urllib.parse import urlparse
parsed = urlparse(vtu_url)
actual_captcha_url = f"{parsed.scheme}://{parsed.netloc}{captcha_src}"
else:
actual_captcha_url = f"{base_url}/{captcha_src}"
else:
actual_captcha_url = captcha_url
# Step 3: Download the captcha image
captcha_response = session.get(actual_captcha_url, headers={
**HEADERS,
'Referer': vtu_url,
}, timeout=10, verify=False)
captcha_response.raise_for_status()
captcha_bytes = captcha_response.content
# Step 4: Solve the captcha
captcha_solution, strategy_used = solve_captcha(captcha_bytes)
if not captcha_solution:
print(f"[WARN] Could not solve captcha for {usn}. Retrying...")
time.sleep(0.5)
continue
# Step 5: Find the form action URL
form = soup.find('form')
if form:
action = form.get('action', '')
if action and not action.startswith('http'):
post_url = f"{base_url}/{action.lstrip('/')}"
elif action:
post_url = action
else:
post_url = vtu_url
else:
post_url = vtu_url
import datetime
import base64
js_token = base64.b64encode(f"student_access_{datetime.datetime.now().year}".encode()).decode()
# Step 6: Submit the form via POST
form_data = {
'Token': token,
'js_token': js_token,
'lns': usn,
'captchacode': captcha_solution,
'submit': 'Submit',
}
if job_state:
job_state['progress'] = f"Submitting form for {usn}..."
result_response = session.post(post_url, data=form_data, headers={
**HEADERS,
'Referer': vtu_url,
'Content-Type': 'application/x-www-form-urlencoded',
}, timeout=15, verify=False)
result_response.raise_for_status()
result_html = result_response.text
# Step 7: Check for captcha errors
if 'Invalid captcha code' in result_html or 'invalid captcha' in result_html.lower():
print(f"[ERROR] Invalid captcha for {usn}. Retrying...")
time.sleep(random.uniform(2.0, 3.5)) # Brief pause before next attempt to avoid rate-limiting
continue
# --- CAPTCHA SUCCESS! ---
evolver.report_success(strategy_used)
# Step 8: Try parsing
if job_state:
job_state['progress'] = f"Parsing results for {usn}..."
student_data, error_reason = parse_result_html(result_html, usn)
if student_data:
return student_data, None
elif error_reason:
return None, error_reason
except Exception as e:
print(f"[{usn}] Network or other error: {e}")
print(f"[FAIL] {usn} completely failed after {max_attempts} attempts.")
return None, "Max captcha retries exceeded or connection timeout"
def fetch_vtu_results(usn_list, vtu_url, job_state=None, semester=None):
"""
Main function to orchestrate the scraping process.
Uses multi-threading + session multiplexing + micro-jitters for smart concurrency without proxies.
"""
MAX_ATTEMPTS = 10
all_results = []
total_usns = len(usn_list)
failed_usns = {} # map of USN -> Reason
print(f"\n[INFO] Starting concurrent scrape for {total_usns} USNs using requests + ddddocr.\n")
# Thread-safe components
results_lock = threading.Lock()
failed_lock = threading.Lock()
def process_usn(usn, i):
if job_state:
job_state['progress'] = f"Processing USN {usn}..."
job_state['current_usn'] = usn
# Check Cache First
cached_data = get_cached_result(usn, vtu_url)
if cached_data:
with results_lock:
all_results.append(cached_data)
if job_state:
job_state['completed'] += 1
print(f"[CACHE HIT] Instantly loaded {usn}")
return
session = requests.Session()
session.verify = False
result, error_reason = fetch_single_result(session, vtu_url, usn, MAX_ATTEMPTS, job_state)
session.close()
if result:
if result.get('semester') is None and semester is not None:
result['semester'] = semester
with results_lock:
all_results.append(result)
if job_state:
job_state['completed'] += 1
save_cached_result(usn, vtu_url, result) # Save to database
print(f"[SUCCESS] Fetched {usn}")
else:
print(f"[FAILED] Failed to fetch {usn} on first pass.")
with failed_lock:
failed_usns[usn] = error_reason
# Launch threads (Max 3 to avoid instant IP ban without proxies)
MAX_WORKERS = 3
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = []
for i, usn in enumerate(usn_list):
futures.append(executor.submit(process_usn, usn, i))
# Wait for first pass to complete
concurrent.futures.wait(futures)
# --- SECOND PASS (For the stubborn captchas) ---
if failed_usns:
print(f"\n[INFO] Doing a second pass for {len(failed_usns)} failed USNs sequentially with higher retry limit...\n")
time.sleep(3) # Let the server breathe before hitting it again
for j, usn in enumerate(failed_usns.keys()):
if job_state:
job_state['progress'] = f"Retry pass for failed USN {usn} ({j + 1}/{len(failed_usns)})"
job_state['current_usn'] = usn
time.sleep(random.uniform(2, 4))
session = requests.Session()
session.verify = False
result, error_reason = fetch_single_result(session, vtu_url, usn, max_attempts=30, job_state=job_state)
session.close()
if result:
if result.get('semester') is None and semester is not None:
result['semester'] = semester
all_results.append(result)
save_cached_result(usn, vtu_url, result)
print(f"[SUCCESS] Fetched {usn} on second pass!")
failed_usns[usn] = None # Clear failure
else:
failed_usns[usn] = error_reason # Update with final failure reason
print(f"[SKIP] Permanently skipped {usn}. May be invalid USN.")
if job_state:
job_state['completed'] += 1
# Sort results by USN to keep them in order
all_results.sort(key=lambda x: x['usn'])
# Filter out successfully retried USNs
final_skipped = [{"usn": u, "reason": r} for u, r in failed_usns.items() if r is not None]
print(f"\n[INFO] Finished Concurrent Scrape. Total successful: {len(all_results)} / {total_usns}")
return all_results, final_skipped |