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