Use natural GST return dashboard navigation in operator agent
This commit is contained in:
@@ -13,7 +13,7 @@ from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
VERSION = "1.2.0"
|
||||
VERSION = "1.3.0"
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DATA = ROOT / "data"
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
@@ -26,6 +26,7 @@ GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generat
|
||||
GSTR2A_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR2A"
|
||||
GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/url?rtn_prd={period}&rtn_typ=GSTR1&file_num={file_num}"
|
||||
GSTR2B_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/api/gstr2b/getjson?rtnprd={period}"
|
||||
ROLESTATUS_URL = "https://return.gst.gov.in/returns/auth/api/rolestatus?rtn_prd={period}"
|
||||
GSTR3B_SUMMARY_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/summary?rtn_prd={period}"
|
||||
GSTR3B_URL = "https://return.gst.gov.in/returns/auth/api/gstr3b/taxpayble?rtn_prd={period}"
|
||||
|
||||
@@ -265,45 +266,188 @@ def open_visible_page(page, url: str, label: str = "GST page", wait_ms: int = 18
|
||||
raise RuntimeError(f"GST portal returned Access Denied / Session Expired while opening {label}.")
|
||||
|
||||
|
||||
def ensure_origin_page(page, url: str) -> None:
|
||||
origin = _origin_for_url(url)
|
||||
if (page.url or "").startswith(origin):
|
||||
return
|
||||
if origin == "https://gstr2b.gst.gov.in":
|
||||
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 2500)
|
||||
elif origin == "https://return.gst.gov.in":
|
||||
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 2500)
|
||||
elif origin == "https://services.gst.gov.in":
|
||||
open_visible_page(page, SERVICES_DASHBOARD_URL, "GST services dashboard", 1800)
|
||||
def close_aadhaar_popup_if_visible(page) -> None:
|
||||
"""Close the optional GST Aadhaar / E-KYC reminder without disturbing login state."""
|
||||
for selector in (
|
||||
"a.btn.btn-primary:has-text('Remind me later')",
|
||||
"text=Remind me later",
|
||||
):
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
if loc.is_visible(timeout=2500):
|
||||
loc.click(timeout=10000)
|
||||
page.wait_for_timeout(1200)
|
||||
_log("Closed GST Aadhaar/E-KYC reminder popup.")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def page_fetch_text(page, url: str, method: str = "GET", payload=None) -> str:
|
||||
ensure_origin_page(page, url)
|
||||
result = page.evaluate(
|
||||
"""async ({url,method,payload})=>{const headers={Accept:'application/json, text/plain, */*'};const opt={method,credentials:'include',headers};if(payload!==null&&payload!==undefined){headers['Content-Type']='application/json;charset=UTF-8';opt.body=JSON.stringify(payload);}const r=await fetch(url,opt);return {status:r.status,ctype:r.headers.get('content-type')||'',text:await r.text(),url:r.url};}""",
|
||||
{"url": url, "method": method, "payload": payload},
|
||||
def is_logged_in_services_page(page) -> bool:
|
||||
url = (page.url or "").lower()
|
||||
return (
|
||||
"services.gst.gov.in/services/auth/fowelcome" in url
|
||||
or "services.gst.gov.in/services/auth/dashboard" in url
|
||||
or ("services.gst.gov.in" in url and "/services/auth/" in url)
|
||||
)
|
||||
status = int(result.get("status") or 0)
|
||||
text = result.get("text") or ""
|
||||
ctype = (result.get("ctype") or "").lower()
|
||||
|
||||
|
||||
def return_to_services_welcome(page, progress, timeout_seconds: int = 60) -> None:
|
||||
"""Return through browser history to the logged-in GST services/welcome page.
|
||||
|
||||
The V16 trial established that GST subdomain sessions are most reliable when
|
||||
navigation starts from the authenticated services page rather than by typing
|
||||
a return/payment URL directly.
|
||||
"""
|
||||
if is_logged_in_services_page(page):
|
||||
close_aadhaar_popup_if_visible(page)
|
||||
return
|
||||
progress(stage="Returning to GST Welcome", message="Returning to the authenticated GST welcome page before the next portal flow.")
|
||||
started = time.time()
|
||||
for _ in range(15):
|
||||
if is_logged_in_services_page(page):
|
||||
close_aadhaar_popup_if_visible(page)
|
||||
return
|
||||
try:
|
||||
page.go_back(wait_until="domcontentloaded", timeout=20000)
|
||||
except Exception as exc:
|
||||
_log(f"GST browser Back warning: {exc}")
|
||||
page.wait_for_timeout(1200)
|
||||
if time.time() - started > timeout_seconds:
|
||||
break
|
||||
if not is_logged_in_services_page(page):
|
||||
raise RuntimeError(
|
||||
"Could not return to the authenticated GST Welcome page. Keep the GST browser open, "
|
||||
"return to the logged-in Home/Welcome page and start the download again."
|
||||
)
|
||||
close_aadhaar_popup_if_visible(page)
|
||||
|
||||
|
||||
def open_return_dashboard_naturally(page, progress) -> None:
|
||||
"""Reach Return Dashboard using the same working V16 human-navigation sequence.
|
||||
|
||||
Never deep-link from a fresh login. GST creates/refreshes the return-domain
|
||||
session when the operator clicks the Return Dashboard control on the logged-in
|
||||
services page. The OctaGST extension likewise expects return.gst.gov.in to be
|
||||
the active dashboard before it calls return APIs.
|
||||
"""
|
||||
if "return.gst.gov.in/returns/auth/dashboard" in (page.url or "").lower():
|
||||
return
|
||||
return_to_services_welcome(page, progress)
|
||||
close_aadhaar_popup_if_visible(page)
|
||||
progress(stage="Opening Return Dashboard", message="Opening Return Dashboard through the GST Welcome page (natural portal navigation).")
|
||||
show_status_overlay(page, "Opening GST Return Dashboard through the portal...", "#0f766e")
|
||||
clicked = False
|
||||
errors = []
|
||||
candidates = [
|
||||
lambda: page.get_by_role("button", name="Return Dashboard"),
|
||||
lambda: page.locator("button:has-text('Return Dashboard')").first,
|
||||
lambda: page.locator("a:has-text('Return Dashboard')").first,
|
||||
lambda: page.get_by_text("Return Dashboard", exact=True).first,
|
||||
]
|
||||
for factory in candidates:
|
||||
try:
|
||||
loc = factory()
|
||||
if loc.is_visible(timeout=4000):
|
||||
loc.click(timeout=15000)
|
||||
clicked = True
|
||||
break
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
if not clicked:
|
||||
raise RuntimeError(
|
||||
"GST Return Dashboard control was not found on the logged-in Welcome page. "
|
||||
"The portal layout may have changed. " + (" | ".join(errors[-2:]) if errors else "")
|
||||
)
|
||||
try:
|
||||
page.wait_for_url("**/returns/auth/dashboard**", timeout=60000)
|
||||
except Exception:
|
||||
# Some portal builds change query/hash while remaining on the same dashboard host.
|
||||
deadline = time.time() + 45
|
||||
while time.time() < deadline:
|
||||
if "return.gst.gov.in" in (page.url or "").lower() and "/returns/auth/" in (page.url or "").lower():
|
||||
break
|
||||
page.wait_for_timeout(1000)
|
||||
try:
|
||||
page.wait_for_load_state("domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(1800)
|
||||
if is_access_denied_page(page):
|
||||
raise RuntimeError(
|
||||
"GST returned Access Denied while entering Return Dashboard through the normal portal flow. "
|
||||
"Logout other GST sessions, login again and retry."
|
||||
)
|
||||
if "return.gst.gov.in" not in (page.url or "").lower():
|
||||
raise RuntimeError(f"GST did not enter the Return Dashboard. Current page: {page.url}")
|
||||
show_status_overlay(page, "GST Return Dashboard ready. Downloading selected return data...", "#15803d")
|
||||
progress(stage="Return Dashboard Ready", message="GST Return Dashboard opened through the normal portal sequence.")
|
||||
|
||||
|
||||
def _same_origin(page, url: str) -> bool:
|
||||
try:
|
||||
return urlsplit(page.url).scheme == urlsplit(url).scheme and urlsplit(page.url).netloc == urlsplit(url).netloc
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def page_fetch_text(page, url: str, method: str = "GET", payload=None, referer: str | None = None) -> str:
|
||||
"""Fetch GST JSON only after Return Dashboard has been established naturally.
|
||||
|
||||
Same-origin return.gst.gov.in calls execute in the visible page just like the
|
||||
OctaGST content-script flow. Cross-subdomain calls (notably GSTR-2B) use the
|
||||
Playwright browser context request client, which shares this browser context's
|
||||
GST cookies, and carries the referer expected by the GST module.
|
||||
"""
|
||||
if is_access_denied_page(page):
|
||||
raise RuntimeError("GST session is on an Access Denied / Session Expired page.")
|
||||
if _same_origin(page, url):
|
||||
result = page.evaluate(
|
||||
"""async ({url,method,payload})=>{const headers={Accept:'application/json, text/plain, */*'};const opt={method,credentials:'include',headers};if(payload!==null&&payload!==undefined){headers['Content-Type']='application/json;charset=UTF-8';opt.body=JSON.stringify(payload);}const r=await fetch(url,opt);return {status:r.status,ctype:r.headers.get('content-type')||'',text:await r.text(),url:r.url};}""",
|
||||
{"url": url, "method": method, "payload": payload},
|
||||
)
|
||||
status = int(result.get("status") or 0)
|
||||
text = result.get("text") or ""
|
||||
ctype = (result.get("ctype") or "").lower()
|
||||
else:
|
||||
headers = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": referer or _origin_for_url(url),
|
||||
}
|
||||
try:
|
||||
if method.upper() == "POST":
|
||||
resp = page.context.request.post(url, data=payload if payload is not None else {}, headers=headers, timeout=60000)
|
||||
else:
|
||||
resp = page.context.request.get(url, headers=headers, timeout=60000)
|
||||
status = int(resp.status or 0)
|
||||
text = resp.text() or ""
|
||||
ctype = (resp.headers.get("content-type") or "").lower()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"GST cross-module request failed for {url}: {exc}") from exc
|
||||
sample = text.lstrip().lower()
|
||||
if status >= 400:
|
||||
raise RuntimeError(f"GST API HTTP {status}: {text[:500]}")
|
||||
if ("text/html" in ctype or sample.startswith(("<!doctype", "<html"))) and not sample.startswith(("{", "[")):
|
||||
raise RuntimeError(
|
||||
"GST returned an HTML/login page instead of return JSON. The portal session/module is not ready."
|
||||
"GST returned an HTML/login page instead of return JSON. The return-domain session is not ready or has expired."
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def prepare_portal_context(page, return_types: list[str]) -> None:
|
||||
selected = {str(x or "").upper() for x in return_types}
|
||||
if "GSTR1" in selected or "GSTR2A" in selected:
|
||||
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GST offline return download page", 2500)
|
||||
if "GSTR2B" in selected:
|
||||
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 2500)
|
||||
if "GSTR3B" in selected:
|
||||
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 2500)
|
||||
def verify_return_dashboard_session(page) -> None:
|
||||
"""Confirm the OctaGST prerequisite: authenticated return.gst.gov.in dashboard."""
|
||||
url = (page.url or "").lower()
|
||||
if "return.gst.gov.in" not in url or "/returns/auth/" not in url:
|
||||
raise RuntimeError("GST Return Dashboard is not active. The agent will not deep-link to a return page.")
|
||||
if is_access_denied_page(page):
|
||||
raise RuntimeError("GST Return Dashboard session is Access Denied / expired.")
|
||||
|
||||
|
||||
def prepare_portal_context(page, return_types: list[str], progress) -> None:
|
||||
# The extension supplied by the user explicitly requires Return Dashboard to
|
||||
# already be open. Keep that one established session for all return API calls.
|
||||
open_return_dashboard_naturally(page, progress)
|
||||
verify_return_dashboard_session(page)
|
||||
|
||||
|
||||
def download_period(page, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
|
||||
@@ -314,8 +458,8 @@ def download_period(page, work_root: Path, period: str, return_types: list[str],
|
||||
|
||||
if "GSTR1" in selected:
|
||||
progress(stage=f"Downloading GSTR-1 {period}", message=f"Generating and downloading GSTR-1 for {period}.")
|
||||
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GSTR-1 offline download page", 1800)
|
||||
generated = page_fetch_text(page, GSTR1_URL.format(period=period))
|
||||
verify_return_dashboard_session(page)
|
||||
generated = page_fetch_text(page, GSTR1_URL.format(period=period), referer=RETURN_DASHBOARD_URL)
|
||||
(raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8")
|
||||
file_num = extract_file_num(generated)
|
||||
content = extract_payload(page_fetch_text(page, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
|
||||
@@ -325,17 +469,17 @@ def download_period(page, work_root: Path, period: str, return_types: list[str],
|
||||
|
||||
if "GSTR2B" in selected:
|
||||
progress(stage=f"Downloading GSTR-2B {period}", message=f"Downloading GSTR-2B JSON for {period}.")
|
||||
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 1800)
|
||||
content = page_fetch_text(page, GSTR2B_URL.format(period=period))
|
||||
verify_return_dashboard_session(page)
|
||||
content = page_fetch_text(page, GSTR2B_URL.format(period=period), referer="https://gstr2b.gst.gov.in")
|
||||
path = raw_dir / f"{period}_GSTR2B.json"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
||||
|
||||
if "GSTR3B" in selected:
|
||||
progress(stage=f"Downloading GSTR-3B {period}", message=f"Downloading GSTR-3B summary and tax payable for {period}.")
|
||||
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 1800)
|
||||
summary = page_fetch_text(page, GSTR3B_SUMMARY_URL.format(period=period))
|
||||
payable = page_fetch_text(page, GSTR3B_URL.format(period=period))
|
||||
verify_return_dashboard_session(page)
|
||||
summary = page_fetch_text(page, GSTR3B_SUMMARY_URL.format(period=period), referer=RETURN_DASHBOARD_URL)
|
||||
payable = page_fetch_text(page, GSTR3B_URL.format(period=period), referer=RETURN_DASHBOARD_URL)
|
||||
(raw_dir / f"{period}_GSTR3B_SUMMARY.json").write_text(summary, encoding="utf-8")
|
||||
(raw_dir / f"{period}_GSTR3B_TAXPAYBLE.json").write_text(payable, encoding="utf-8")
|
||||
try:
|
||||
@@ -354,15 +498,15 @@ def download_period(page, work_root: Path, period: str, return_types: list[str],
|
||||
|
||||
if "GSTR2A" in selected:
|
||||
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A offline return for {period}.")
|
||||
open_visible_page(page, OFFLINE_DOWNLOAD_PAGE_URL, "GSTR-2A offline download page", 1800)
|
||||
content = page_fetch_text(page, GSTR2A_URL.format(period=period))
|
||||
verify_return_dashboard_session(page)
|
||||
content = page_fetch_text(page, GSTR2A_URL.format(period=period), referer=RETURN_DASHBOARD_URL)
|
||||
path = raw_dir / f"{period}_GSTR2A.json"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
downloaded.append({"return_type": "GSTR2A", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
||||
|
||||
write_json(
|
||||
work_root / period / "download_manifest.json",
|
||||
{"period": period, "downloaded_at_utc": now(), "source": "arrr_gst_operator_agent_1.2.0", "downloaded": downloaded},
|
||||
{"period": period, "downloaded_at_utc": now(), "source": "arrr_gst_operator_agent_1.3.0", "downloaded": downloaded},
|
||||
)
|
||||
return downloaded
|
||||
|
||||
@@ -426,7 +570,7 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
||||
wait_for_login(page, progress, int(payload.get("login_timeout_seconds") or 900))
|
||||
periods = [str(p) for p in payload.get("periods") or []]
|
||||
return_types = [str(r) for r in payload.get("return_types") or []]
|
||||
prepare_portal_context(page, return_types)
|
||||
prepare_portal_context(page, return_types, progress)
|
||||
all_downloaded = []
|
||||
for index, period in enumerate(periods, 1):
|
||||
pct = 15 + int((index - 1) * 70 / max(1, len(periods)))
|
||||
|
||||
Reference in New Issue
Block a user