Project

General

Profile

Feature #154 » nsdl_browser.py

Python script: from __future__ import annotations - Akshath Shettigara, 10/15/2026 04:35 PM

 
from __future__ import annotations

import time


def idle_keepalive_pause(page, total_seconds: int, chunk_seconds: int = 5) -> None:
"""
Wait out a required cooldown (the 15-PDF pause, the rate-limit backoff,
etc.) WITHOUT letting the browser tab go completely idle.

Ported from eap_sez_automation.py. A plain wait_for_timeout(120_000)
leaves the page with zero activity for two full minutes -- which lines
up exactly with when the NSDL session was observed expiring mid-run.
This performs small, harmless mouse-wheel scrolls (down for a few
seconds, then up, repeating) so the tab registers ordinary user
activity for the whole cooldown, while still taking the full required
duration and making no extra network requests (so it can't trip the
portal's rate limit itself). This is a best-effort measure, not a
guarantee -- session-expiry detection/recovery should still run
immediately afterward as the real safety net.
"""
elapsed = 0.0
direction = 1 # 1 = scroll down, -1 = scroll up

while elapsed < total_seconds:
chunk_start = time.time()
remaining = total_seconds - elapsed
this_chunk = min(chunk_seconds, remaining)

try:
steps = max(1, round(this_chunk))
step_delta = (300 * direction) / steps
step_wait_ms = int((this_chunk / steps) * 1000)

for _ in range(steps):
page.mouse.wheel(0, step_delta)
page.wait_for_timeout(step_wait_ms)
except Exception:
# Page/tab not in a state where scrolling is possible right now
# -- fall back to a plain wait for this chunk. Session-expiry
# detection elsewhere still catches an actual timeout.
time.sleep(this_chunk)

direction *= -1
elapsed += time.time() - chunk_start


def immunize_page_against_popups(context) -> None:
"""
Install persistent protection against the NSDL driver.js welcome tour and
known overlay elements that can block normal portal interaction.

A modest polling interval is intentionally used instead of a document-wide
MutationObserver because NSDL performs frequent ASP.NET/AJAX DOM updates;
a global observer can create a DOM-mutation feedback loop and freeze the UI.
"""
context.add_init_script(
"""
window.Driver = function() {
return {
start: function() {},
reset: function() {},
destroy: function() {},
defineSteps: function() {},
showNextStep: function() {},
preventMove: function() {}
};
};

const installNsdlPopupProtection = () => {
const style = document.createElement('style');
style.innerHTML = `
.driver-overlay,
.driver-popover,
.driver-popover-tip,
.driver-popover-footer,
.driver-popover-description,
.driver-popover-title,
.driver-stage-no-animation,
.modal-backdrop,
.overlay {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
opacity: 0 !important;
}

html,
body {
overflow: auto !important;
pointer-events: auto !important;
}
`;

if (document.head) {
document.head.appendChild(style);
}

const stripOverlayState = () => {
if (document.body) {
document.body.classList.remove('driver-active', 'driver-fade');
document.body.style.overflow = 'auto';
document.body.style.pointerEvents = 'auto';
}

if (document.documentElement) {
document.documentElement.classList.remove('driver-active', 'driver-fade');
document.documentElement.style.overflow = 'auto';
}

document.querySelectorAll(
'.driver-overlay, .driver-popover, .driver-popover-tip, ' +
'.driver-popover-footer, .driver-popover-description, ' +
'.driver-popover-title, .modal-backdrop, .overlay'
).forEach((el) => el.remove());
};

stripOverlayState();
setInterval(stripOverlayState, 400);
};

if (document.body) {
installNsdlPopupProtection();
} else {
document.addEventListener('DOMContentLoaded', installNsdlPopupProtection);
}
"""
)


def clean_popups_now(page) -> None:
"""Remove known popup/overlay elements from the active page immediately."""
try:
page.evaluate(
"""
document.querySelectorAll(
'.driver-overlay, .driver-popover, .driver-popover-tip, ' +
'.driver-popover-footer, .driver-popover-description, ' +
'.driver-popover-title, .modal-backdrop, .overlay'
).forEach((el) => el.remove());

if (document.body) {
document.body.classList.remove('driver-active', 'driver-fade');
document.body.style.overflow = 'auto';
document.body.style.pointerEvents = 'auto';
}

if (document.documentElement) {
document.documentElement.classList.remove('driver-active', 'driver-fade');
document.documentElement.style.overflow = 'auto';
}
"""
)
except Exception:
pass


def handle_welcome_popup(page) -> None:
"""Belt-and-braces runtime close of the NSDL Welcome Tour, then cleanup."""
try:
page.wait_for_timeout(500)
popup = page.locator(".driver-popover")
close_btn = page.locator(".driver-popover-close-btn")

if popup.count() > 0 and popup.is_visible():
try:
close_btn.first.evaluate("(el) => el.click()")
except Exception:
try:
close_btn.first.click(timeout=3000, force=True)
except Exception:
pass

try:
popup.wait_for(state="hidden", timeout=5000)
except Exception:
pass
except Exception:
pass

clean_popups_now(page)


def safe_click(page, locator_or_selector) -> None:
"""
Reliable NSDL click wrapper.

The popup layer is cleaned immediately before the click. A forced click is
attempted first, followed by a normal Playwright click as a second attempt.
"""
clean_popups_now(page)

locator = locator_or_selector if hasattr(locator_or_selector, "click") else page.locator(locator_or_selector)

try:
locator.scroll_into_view_if_needed(timeout=5000)
except Exception:
pass

clean_popups_now(page)

try:
locator.click(timeout=10000, force=True)
return
except Exception as first_exc:
clean_popups_now(page)
try:
locator.click(timeout=10000)
return
except Exception as second_exc:
raise RuntimeError(f"Could not click NSDL portal control: {second_exc}") from first_exc


def enter_masked_date(page, locator, value: str) -> None:
"""Set an NSDL masked date field using the portal's expected interaction."""
clean_popups_now(page)
safe_click(page, locator)

try:
locator.evaluate("(el) => el.removeAttribute('readonly')")
except Exception:
pass

locator.evaluate("(el) => el.value = ''")
if value:
locator.type(value, delay=50)

clean_popups_now(page)
(3-3/3)