KindAlien's picture
Update scraper.py
3498507 verified
Raw
History Blame Contribute Delete
21.5 kB
# 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