|
|
@@ -3,21 +3,25 @@ from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import json
|
|
|
|
import re
|
|
|
|
import re
|
|
|
|
import shutil
|
|
|
|
import shutil
|
|
|
|
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
import time
|
|
|
|
|
|
|
|
import traceback
|
|
|
|
import zipfile
|
|
|
|
import zipfile
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from pathlib import Path
|
|
|
|
from pathlib import Path
|
|
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import sys
|
|
|
|
|
|
|
|
import traceback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
|
|
VERSION = "1.1.0"
|
|
|
|
VERSION = "1.2.0"
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
DATA = ROOT / "data"
|
|
|
|
DATA = ROOT / "data"
|
|
|
|
CONFIG_PATH = ROOT / "config.json"
|
|
|
|
CONFIG_PATH = ROOT / "config.json"
|
|
|
|
GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
|
|
|
|
GST_LOGIN_URL = "https://services.gst.gov.in/services/login"
|
|
|
|
|
|
|
|
SERVICES_DASHBOARD_URL = "https://services.gst.gov.in/services/auth/dashboard"
|
|
|
|
|
|
|
|
RETURN_DASHBOARD_URL = "https://return.gst.gov.in/returns/auth/dashboard"
|
|
|
|
|
|
|
|
OFFLINE_DOWNLOAD_PAGE_URL = "https://return.gst.gov.in/returns/auth/gstr/offlinedownload"
|
|
|
|
|
|
|
|
GSTR2B_PAGE_URL = "https://gstr2b.gst.gov.in/gstr2b/auth/gstr2bdwld"
|
|
|
|
GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR1"
|
|
|
|
GSTR1_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR1"
|
|
|
|
GSTR2A_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/generate?flag=0&rtn_prd={period}&rtn_typ=GSTR2A"
|
|
|
|
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}"
|
|
|
|
GSTR1_DOWNLOAD_URL = "https://return.gst.gov.in/returns/auth/api/offline/download/url?rtn_prd={period}&rtn_typ=GSTR1&file_num={file_num}"
|
|
|
@@ -90,49 +94,248 @@ def extract_payload(text: str) -> str:
|
|
|
|
except Exception:
|
|
|
|
except Exception:
|
|
|
|
return text or ""
|
|
|
|
return text or ""
|
|
|
|
if isinstance(data, dict):
|
|
|
|
if isinstance(data, dict):
|
|
|
|
for key in ("data", "payload", "json", "response"):
|
|
|
|
for key in ("data", "payload", "json", "response", "content", "fileContent"):
|
|
|
|
value = data.get(key)
|
|
|
|
value = data.get(key)
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
return json.dumps(value, ensure_ascii=False)
|
|
|
|
return json.dumps(value, ensure_ascii=False)
|
|
|
|
if isinstance(value, str) and value.strip().startswith(("{", "[")):
|
|
|
|
if isinstance(value, str) and value.strip().startswith(("{", "[")):
|
|
|
|
return value
|
|
|
|
return value
|
|
|
|
|
|
|
|
if any(k in data for k in ("b2b", "b2cl", "b2cs", "cdnr", "gstin")):
|
|
|
|
|
|
|
|
return text
|
|
|
|
return text or ""
|
|
|
|
return text or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api_text(context, url: str) -> str:
|
|
|
|
def _log(message: str) -> None:
|
|
|
|
response = context.request.get(url, timeout=90000)
|
|
|
|
try:
|
|
|
|
if not response.ok:
|
|
|
|
DATA.mkdir(parents=True, exist_ok=True)
|
|
|
|
raise RuntimeError(f"GST portal request failed ({response.status}) for {url.split('?')[0]}")
|
|
|
|
with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
|
|
|
|
return response.text()
|
|
|
|
handle.write(f"[{now()}] {message}\n")
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def download_period(context, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
|
|
|
|
def _show_error(message: str) -> None:
|
|
|
|
|
|
|
|
_log("ERROR: " + message)
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
import tkinter as tk
|
|
|
|
|
|
|
|
from tkinter import messagebox
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
root = tk.Tk()
|
|
|
|
|
|
|
|
root.withdraw()
|
|
|
|
|
|
|
|
messagebox.showerror("ARRR GST Operator Agent", message)
|
|
|
|
|
|
|
|
root.destroy()
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_access_denied_page(page) -> bool:
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
url = (page.url or "").lower()
|
|
|
|
|
|
|
|
if "accessdenied" in url or "session expired" in url:
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
text = (page.locator("body").inner_text(timeout=1500) or "").lower()
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
text = ""
|
|
|
|
|
|
|
|
return "access denied" in text or "session expired" in text or "your session expired" in text
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_captcha_overlay(page) -> None:
|
|
|
|
|
|
|
|
js = r"""
|
|
|
|
|
|
|
|
async () => {
|
|
|
|
|
|
|
|
return await new Promise((resolve) => {
|
|
|
|
|
|
|
|
const old = document.getElementById('arrr-gst-captcha-overlay'); if (old) old.remove();
|
|
|
|
|
|
|
|
const overlay = document.createElement('div'); overlay.id='arrr-gst-captcha-overlay';
|
|
|
|
|
|
|
|
Object.assign(overlay.style,{position:'fixed',inset:'0',zIndex:'2147483647',background:'rgba(15,23,42,.60)',display:'flex',alignItems:'center',justifyContent:'center',fontFamily:'Arial,sans-serif'});
|
|
|
|
|
|
|
|
const box=document.createElement('div'); Object.assign(box.style,{background:'#fff',borderRadius:'12px',boxShadow:'0 20px 60px rgba(0,0,0,.35)',width:'540px',maxWidth:'90vw',padding:'24px',color:'#111827'});
|
|
|
|
|
|
|
|
const title=document.createElement('div'); title.innerText='GST Login Captcha Required'; Object.assign(title.style,{fontWeight:'700',fontSize:'20px',marginBottom:'12px'});
|
|
|
|
|
|
|
|
const body=document.createElement('div'); body.innerText='Username and password are filled.\\n\\nClick OK, enter CAPTCHA in the GST page, complete OTP if asked, and Login.\\n\\nARRR GST Operator Agent will verify the authenticated session automatically and then download the selected returns.'; Object.assign(body.style,{fontSize:'15px',lineHeight:'1.5',whiteSpace:'pre-line',marginBottom:'18px'});
|
|
|
|
|
|
|
|
const btn=document.createElement('button'); btn.innerText='OK - Enter Captcha'; btn.type='button'; Object.assign(btn.style,{background:'#1d4ed8',color:'#fff',border:'0',borderRadius:'8px',padding:'10px 16px',fontWeight:'700',cursor:'pointer'});
|
|
|
|
|
|
|
|
btn.onclick=()=>{overlay.remove(); setTimeout(()=>{const c=document.querySelector('#captcha, input[name="captcha"], input[placeholder*="Captcha"]'); if(c){c.scrollIntoView({block:'center'});c.focus();c.click();}},150); resolve(true);};
|
|
|
|
|
|
|
|
box.append(title,body,btn); overlay.appendChild(box); document.body.appendChild(overlay); btn.focus();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
page.evaluate(js)
|
|
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
|
|
_log(f"Captcha overlay warning: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def show_status_overlay(page, message: str, color: str = "#0f172a") -> None:
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
page.evaluate(
|
|
|
|
|
|
|
|
"""({message,color})=>{let b=document.getElementById('arrr-gst-status');if(!b){b=document.createElement('div');b.id='arrr-gst-status';Object.assign(b.style,{position:'fixed',right:'18px',bottom:'18px',zIndex:'2147483647',color:'#fff',borderRadius:'12px',padding:'14px 18px',boxShadow:'0 12px 40px rgba(0,0,0,.35)',fontFamily:'Arial,sans-serif',fontSize:'14px',maxWidth:'440px'});document.body.appendChild(b);}b.style.background=color;b.innerText=message;}""",
|
|
|
|
|
|
|
|
{"message": message, "color": color},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_valid_ustatus_json(data) -> bool:
|
|
|
|
|
|
|
|
if not isinstance(data, dict) or not data:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
low = json.dumps(data, default=str).lower()[:3000]
|
|
|
|
|
|
|
|
if any(x in low for x in ("access denied", "accessdenied", "session expired", "login required", "invalid session")):
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
keys = {str(k).lower() for k in data}
|
|
|
|
|
|
|
|
if keys & {"utype", "llogin", "lastlogin", "gstin", "gstinno", "username", "legalname", "tradename", "lgnm", "trade_name"}:
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
meaningful = sum(
|
|
|
|
|
|
|
|
1
|
|
|
|
|
|
|
|
for value in data.values()
|
|
|
|
|
|
|
|
if isinstance(value, (str, int, float, bool)) and str(value).strip() not in ("", "None", "null")
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return meaningful >= 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_gst_session(page) -> tuple[bool, str]:
|
|
|
|
|
|
|
|
for url in (
|
|
|
|
|
|
|
|
"https://services.gst.gov.in/services/api/ustatus",
|
|
|
|
|
|
|
|
"https://return.gst.gov.in/services/api/ustatus",
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
resp = page.context.request.get(url, timeout=20000)
|
|
|
|
|
|
|
|
text = resp.text() or ""
|
|
|
|
|
|
|
|
ctype = (resp.headers.get("content-type") or "").lower()
|
|
|
|
|
|
|
|
if resp.status == 200 and "json" in ctype:
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
data = json.loads(text or "{}")
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
data = None
|
|
|
|
|
|
|
|
if _looks_like_valid_ustatus_json(data):
|
|
|
|
|
|
|
|
return True, f"authenticated session confirmed from {url}"
|
|
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
|
|
_log(f"GST session check warning {url}: {exc}")
|
|
|
|
|
|
|
|
return False, "GST authenticated session not confirmed yet"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wait_for_login(page, progress, timeout_seconds: int = 900) -> None:
|
|
|
|
|
|
|
|
inject_captcha_overlay(page)
|
|
|
|
|
|
|
|
progress(
|
|
|
|
|
|
|
|
percent=10,
|
|
|
|
|
|
|
|
stage="Waiting for CAPTCHA / OTP",
|
|
|
|
|
|
|
|
message="Username/password filled. Complete CAPTCHA/OTP and login in the visible GST browser. Session is checked automatically.",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
started = time.time()
|
|
|
|
|
|
|
|
while time.time() - started < timeout_seconds:
|
|
|
|
|
|
|
|
if is_access_denied_page(page):
|
|
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
|
|
"GST portal reported Access Denied / Session Expired. Close other GST sessions and start the download again."
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
ok, reason = check_gst_session(page)
|
|
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
|
|
progress(percent=15, stage="GST Login Verified", message=reason)
|
|
|
|
|
|
|
|
show_status_overlay(
|
|
|
|
|
|
|
|
page,
|
|
|
|
|
|
|
|
"GST login verified. ARRR is downloading the selected GST returns now.",
|
|
|
|
|
|
|
|
"#065f46",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
|
|
raise RuntimeError("GST login/session was not confirmed within 15 minutes.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _origin_for_url(url: str) -> str:
|
|
|
|
|
|
|
|
for origin in (
|
|
|
|
|
|
|
|
"https://gstr2b.gst.gov.in",
|
|
|
|
|
|
|
|
"https://return.gst.gov.in",
|
|
|
|
|
|
|
|
"https://services.gst.gov.in",
|
|
|
|
|
|
|
|
"https://payment.gst.gov.in",
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
if origin.split("//", 1)[1] in (url or ""):
|
|
|
|
|
|
|
|
return origin
|
|
|
|
|
|
|
|
return "https://return.gst.gov.in"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def open_visible_page(page, url: str, label: str = "GST page", wait_ms: int = 1800) -> None:
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
page.bring_to_front()
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
page.goto(url, wait_until="domcontentloaded", timeout=90000)
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
page.evaluate("url=>window.location.href=url", url)
|
|
|
|
|
|
|
|
page.wait_for_load_state("domcontentloaded", timeout=90000)
|
|
|
|
|
|
|
|
page.wait_for_timeout(wait_ms)
|
|
|
|
|
|
|
|
if is_access_denied_page(page):
|
|
|
|
|
|
|
|
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 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},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
status = int(result.get("status") or 0)
|
|
|
|
|
|
|
|
text = result.get("text") or ""
|
|
|
|
|
|
|
|
ctype = (result.get("ctype") or "").lower()
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
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 download_period(page, work_root: Path, period: str, return_types: list[str], progress) -> list[dict]:
|
|
|
|
raw_dir = work_root / period / "raw"
|
|
|
|
raw_dir = work_root / period / "raw"
|
|
|
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
selected = {str(x or "").upper() for x in return_types}
|
|
|
|
selected = {str(x or "").upper() for x in return_types}
|
|
|
|
downloaded: list[dict] = []
|
|
|
|
downloaded: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
if "GSTR1" in selected:
|
|
|
|
if "GSTR1" in selected:
|
|
|
|
progress(stage=f"Downloading GSTR-1 {period}", message=f"Reading GSTR-1 for {period}.")
|
|
|
|
progress(stage=f"Downloading GSTR-1 {period}", message=f"Generating and downloading GSTR-1 for {period}.")
|
|
|
|
generated = api_text(context, GSTR1_URL.format(period=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))
|
|
|
|
(raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8")
|
|
|
|
(raw_dir / f"{period}_GSTR1_GENERATE.json").write_text(generated, encoding="utf-8")
|
|
|
|
file_num = extract_file_num(generated)
|
|
|
|
file_num = extract_file_num(generated)
|
|
|
|
content = extract_payload(api_text(context, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
|
|
|
|
content = extract_payload(page_fetch_text(page, GSTR1_DOWNLOAD_URL.format(period=period, file_num=file_num)))
|
|
|
|
path = raw_dir / f"{period}_GSTR1.json"
|
|
|
|
path = raw_dir / f"{period}_GSTR1.json"
|
|
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
downloaded.append({"return_type": "GSTR1", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
downloaded.append({"return_type": "GSTR1", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
|
|
|
|
|
|
|
|
if "GSTR2B" in selected:
|
|
|
|
if "GSTR2B" in selected:
|
|
|
|
progress(stage=f"Downloading GSTR-2B {period}", message=f"Reading GSTR-2B for {period}.")
|
|
|
|
progress(stage=f"Downloading GSTR-2B {period}", message=f"Downloading GSTR-2B JSON for {period}.")
|
|
|
|
content = api_text(context, GSTR2B_URL.format(period=period))
|
|
|
|
open_visible_page(page, GSTR2B_PAGE_URL, "GSTR-2B download page", 1800)
|
|
|
|
|
|
|
|
content = page_fetch_text(page, GSTR2B_URL.format(period=period))
|
|
|
|
path = raw_dir / f"{period}_GSTR2B.json"
|
|
|
|
path = raw_dir / f"{period}_GSTR2B.json"
|
|
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
downloaded.append({"return_type": "GSTR2B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
|
|
|
|
|
|
|
|
if "GSTR3B" in selected:
|
|
|
|
if "GSTR3B" in selected:
|
|
|
|
progress(stage=f"Downloading GSTR-3B {period}", message=f"Reading GSTR-3B for {period}.")
|
|
|
|
progress(stage=f"Downloading GSTR-3B {period}", message=f"Downloading GSTR-3B summary and tax payable for {period}.")
|
|
|
|
summary = api_text(context, GSTR3B_SUMMARY_URL.format(period=period))
|
|
|
|
open_visible_page(page, RETURN_DASHBOARD_URL, "GST Return Dashboard", 1800)
|
|
|
|
payable = api_text(context, GSTR3B_URL.format(period=period))
|
|
|
|
summary = page_fetch_text(page, GSTR3B_SUMMARY_URL.format(period=period))
|
|
|
|
|
|
|
|
payable = page_fetch_text(page, GSTR3B_URL.format(period=period))
|
|
|
|
(raw_dir / f"{period}_GSTR3B_SUMMARY.json").write_text(summary, encoding="utf-8")
|
|
|
|
(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")
|
|
|
|
(raw_dir / f"{period}_GSTR3B_TAXPAYBLE.json").write_text(payable, encoding="utf-8")
|
|
|
|
try:
|
|
|
|
try:
|
|
|
@@ -150,35 +353,24 @@ def download_period(context, work_root: Path, period: str, return_types: list[st
|
|
|
|
downloaded.append({"return_type": "GSTR3B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
downloaded.append({"return_type": "GSTR3B", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
|
|
|
|
|
|
|
|
if "GSTR2A" in selected:
|
|
|
|
if "GSTR2A" in selected:
|
|
|
|
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A for {period}.")
|
|
|
|
progress(stage=f"Requesting GSTR-2A {period}", message=f"Requesting GSTR-2A offline return for {period}.")
|
|
|
|
content = api_text(context, GSTR2A_URL.format(period=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))
|
|
|
|
path = raw_dir / f"{period}_GSTR2A.json"
|
|
|
|
path = raw_dir / f"{period}_GSTR2A.json"
|
|
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
downloaded.append({"return_type": "GSTR2A", "path": str(path.relative_to(work_root)), "bytes": path.stat().st_size})
|
|
|
|
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", {
|
|
|
|
write_json(
|
|
|
|
"period": period, "downloaded_at_utc": now(), "source": "gst_lightweight_operator_agent", "downloaded": downloaded
|
|
|
|
work_root / period / "download_manifest.json",
|
|
|
|
})
|
|
|
|
{"period": period, "downloaded_at_utc": now(), "source": "arrr_gst_operator_agent_1.2.0", "downloaded": downloaded},
|
|
|
|
|
|
|
|
)
|
|
|
|
return downloaded
|
|
|
|
return downloaded
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def login_complete(page) -> bool:
|
|
|
|
|
|
|
|
url = (page.url or "").lower()
|
|
|
|
|
|
|
|
if "/dashboard" in url or "/returns" in url:
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
return bool(page.locator("text=Search Taxpayer").count() and not page.locator("#username").count())
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
job_id = str(payload.get("jti") or "")
|
|
|
|
job_id = str(payload.get("jti") or "")
|
|
|
|
work_root = DATA / "work" / safe(job_id)
|
|
|
|
work_root = DATA / "work" / safe(job_id)
|
|
|
|
package_path = DATA / f"gst_{safe(job_id)}.zip"
|
|
|
|
package_path = DATA / f"gst_{safe(job_id)}.zip"
|
|
|
|
try:
|
|
|
|
|
|
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
|
|
|
|
|
|
work_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def progress(**updates):
|
|
|
|
def progress(**updates):
|
|
|
|
current = read_json(path)
|
|
|
|
current = read_json(path)
|
|
|
@@ -186,8 +378,12 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
current["updated_at_utc"] = now()
|
|
|
|
current["updated_at_utc"] = now()
|
|
|
|
write_json(path, current)
|
|
|
|
write_json(path, current)
|
|
|
|
|
|
|
|
|
|
|
|
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge on this workstation.")
|
|
|
|
try:
|
|
|
|
|
|
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
|
|
|
|
|
|
work_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
progress(status="running", percent=5, stage="Opening GST Login", message="Opening GST Portal in visible Chrome/Edge.")
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
|
|
|
|
|
|
|
|
with sync_playwright() as pw:
|
|
|
|
with sync_playwright() as pw:
|
|
|
|
context = None
|
|
|
|
context = None
|
|
|
|
errors = []
|
|
|
|
errors = []
|
|
|
@@ -196,20 +392,24 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
for channel in ("chrome", "msedge"):
|
|
|
|
for channel in ("chrome", "msedge"):
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
context = pw.chromium.launch_persistent_context(
|
|
|
|
context = pw.chromium.launch_persistent_context(
|
|
|
|
user_data_dir=str(profile_root / channel), channel=channel, headless=False,
|
|
|
|
user_data_dir=str(profile_root / f"{channel}_{safe(job_id)}"),
|
|
|
|
no_viewport=True, accept_downloads=True,
|
|
|
|
channel=channel,
|
|
|
|
|
|
|
|
headless=False,
|
|
|
|
|
|
|
|
no_viewport=True,
|
|
|
|
|
|
|
|
accept_downloads=True,
|
|
|
|
args=["--start-maximized", "--no-first-run", "--disable-blink-features=AutomationControlled"],
|
|
|
|
args=["--start-maximized", "--no-first-run", "--disable-blink-features=AutomationControlled"],
|
|
|
|
|
|
|
|
timeout=45000,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
break
|
|
|
|
break
|
|
|
|
except Exception as exc:
|
|
|
|
except Exception as exc:
|
|
|
|
errors.append(f"{channel}: {exc}")
|
|
|
|
errors.append(f"{channel}: {exc}")
|
|
|
|
if context is None:
|
|
|
|
if context is None:
|
|
|
|
raise RuntimeError("Could not open installed Chrome or Edge. " + " | ".join(errors))
|
|
|
|
raise RuntimeError("Could not open installed Chrome or Edge. " + " | ".join(errors))
|
|
|
|
|
|
|
|
|
|
|
|
page = context.pages[0] if context.pages else context.new_page()
|
|
|
|
page = context.pages[0] if context.pages else context.new_page()
|
|
|
|
page.goto(GST_LOGIN_URL, wait_until="domcontentloaded", timeout=90000)
|
|
|
|
open_visible_page(page, GST_LOGIN_URL, "GST Login", 1000)
|
|
|
|
page.locator("#username").wait_for(state="visible", timeout=30000)
|
|
|
|
page.locator("#username").wait_for(state="visible", timeout=30000)
|
|
|
|
page.fill("#username", str(payload.get("username") or ""))
|
|
|
|
page.fill("#username", str(payload.get("username") or ""))
|
|
|
|
# GST portal has used user_pass for the password field; fall back to generic password locator.
|
|
|
|
|
|
|
|
password_filled = False
|
|
|
|
password_filled = False
|
|
|
|
for selector in ("#user_pass", "input[type=password]"):
|
|
|
|
for selector in ("#user_pass", "input[type=password]"):
|
|
|
|
try:
|
|
|
|
try:
|
|
|
@@ -221,25 +421,44 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
except Exception:
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
pass
|
|
|
|
if not password_filled:
|
|
|
|
if not password_filled:
|
|
|
|
raise RuntimeError("GST password field could not be located. The portal login page may have changed.")
|
|
|
|
raise RuntimeError("GST password field could not be located. The GST login page may have changed.")
|
|
|
|
progress(percent=10, stage="Waiting for CAPTCHA / OTP", message="GST username/password filled. Complete CAPTCHA/OTP in the visible GST browser.")
|
|
|
|
|
|
|
|
deadline = time.time() + int(payload.get("login_timeout_seconds") or 900)
|
|
|
|
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
|
|
|
if login_complete(page):
|
|
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
time.sleep(2)
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
raise RuntimeError("GST login was not completed within 15 minutes.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
wait_for_login(page, progress, int(payload.get("login_timeout_seconds") or 900))
|
|
|
|
periods = [str(p) for p in payload.get("periods") or []]
|
|
|
|
periods = [str(p) for p in payload.get("periods") or []]
|
|
|
|
return_types = [str(r) for r in payload.get("return_types") or []]
|
|
|
|
return_types = [str(r) for r in payload.get("return_types") or []]
|
|
|
|
|
|
|
|
prepare_portal_context(page, return_types)
|
|
|
|
all_downloaded = []
|
|
|
|
all_downloaded = []
|
|
|
|
for index, period in enumerate(periods, 1):
|
|
|
|
for index, period in enumerate(periods, 1):
|
|
|
|
base_pct = 10 + int((index - 1) * 75 / max(1, len(periods)))
|
|
|
|
pct = 15 + int((index - 1) * 70 / max(1, len(periods)))
|
|
|
|
progress(percent=base_pct, period=period, period_index=index, period_total=len(periods), stage=f"Period {index}/{len(periods)}", message=f"Downloading selected GST returns for {period}.")
|
|
|
|
progress(
|
|
|
|
all_downloaded.extend(download_period(context, work_root, period, return_types, progress))
|
|
|
|
percent=pct,
|
|
|
|
|
|
|
|
period=period,
|
|
|
|
|
|
|
|
period_index=index,
|
|
|
|
|
|
|
|
period_total=len(periods),
|
|
|
|
|
|
|
|
stage=f"Period {index}/{len(periods)}",
|
|
|
|
|
|
|
|
message=f"Downloading selected GST returns for {period}.",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
all_downloaded.extend(download_period(page, work_root, period, return_types, progress))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
show_status_overlay(
|
|
|
|
|
|
|
|
page,
|
|
|
|
|
|
|
|
"Selected GST return downloads are complete. Transferring them to ARRR client storage.",
|
|
|
|
|
|
|
|
"#065f46",
|
|
|
|
|
|
|
|
)
|
|
|
|
context.close()
|
|
|
|
context.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
write_json(
|
|
|
|
|
|
|
|
work_root / "job_manifest.json",
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"job_id": job_id,
|
|
|
|
|
|
|
|
"gstin": payload.get("gstin"),
|
|
|
|
|
|
|
|
"financial_year": payload.get("financial_year"),
|
|
|
|
|
|
|
|
"periods": payload.get("periods") or [],
|
|
|
|
|
|
|
|
"return_types": payload.get("return_types") or [],
|
|
|
|
|
|
|
|
"downloaded": all_downloaded,
|
|
|
|
|
|
|
|
"completed_at_utc": now(),
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
if package_path.exists():
|
|
|
|
if package_path.exists():
|
|
|
|
package_path.unlink()
|
|
|
|
package_path.unlink()
|
|
|
|
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
|
|
|
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
|
|
@@ -247,11 +466,20 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
if item.is_file():
|
|
|
|
if item.is_file():
|
|
|
|
archive.write(item, item.relative_to(work_root).as_posix())
|
|
|
|
archive.write(item, item.relative_to(work_root).as_posix())
|
|
|
|
|
|
|
|
|
|
|
|
progress(percent=90, stage="Transferring to Client Storage", message="Uploading GST return package to ERP for transfer to the configured Storage Agent.")
|
|
|
|
progress(
|
|
|
|
|
|
|
|
percent=90,
|
|
|
|
|
|
|
|
stage="Transferring to Client Storage",
|
|
|
|
|
|
|
|
message="Uploading GST return package to ERP for transfer to configured Storage Agent.",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
upload_url = str(payload.get("upload_url") or "").strip()
|
|
|
|
|
|
|
|
if not upload_url.startswith("https://"):
|
|
|
|
|
|
|
|
raise RuntimeError(f"ERP upload URL is invalid: {upload_url or '(empty)'}")
|
|
|
|
with package_path.open("rb") as handle:
|
|
|
|
with package_path.open("rb") as handle:
|
|
|
|
response = requests.post(
|
|
|
|
response = requests.post(
|
|
|
|
str(payload.get("upload_url") or ""), data={"token": token},
|
|
|
|
upload_url,
|
|
|
|
files={"package": (package_path.name, handle, "application/zip")}, timeout=240,
|
|
|
|
data={"token": token},
|
|
|
|
|
|
|
|
files={"package": (package_path.name, handle, "application/zip")},
|
|
|
|
|
|
|
|
timeout=240,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
body = response.json()
|
|
|
|
body = response.json()
|
|
|
@@ -259,11 +487,29 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
body = {"ok": False, "error": response.text[:1000]}
|
|
|
|
body = {"ok": False, "error": response.text[:1000]}
|
|
|
|
if not response.ok or not body.get("ok"):
|
|
|
|
if not response.ok or not body.get("ok"):
|
|
|
|
raise RuntimeError(body.get("error") or f"ERP storage transfer failed with HTTP {response.status_code}.")
|
|
|
|
raise RuntimeError(body.get("error") or f"ERP storage transfer failed with HTTP {response.status_code}.")
|
|
|
|
progress(status="completed", percent=100, stage="Completed", message="GST returns downloaded and stored in the configured client local storage.", result={"stored": body.get("stored") or {}}, finished_at_utc=now())
|
|
|
|
progress(
|
|
|
|
|
|
|
|
status="completed",
|
|
|
|
|
|
|
|
percent=100,
|
|
|
|
|
|
|
|
stage="Completed",
|
|
|
|
|
|
|
|
message="GST returns downloaded and stored in configured client local storage.",
|
|
|
|
|
|
|
|
result={"stored": body.get("stored") or {}},
|
|
|
|
|
|
|
|
finished_at_utc=now(),
|
|
|
|
|
|
|
|
)
|
|
|
|
except Exception as exc:
|
|
|
|
except Exception as exc:
|
|
|
|
progress = read_json(path)
|
|
|
|
current = read_json(path)
|
|
|
|
progress.update({"status": "failed", "percent": 100, "stage": "Failed", "message": str(exc), "error": str(exc), "finished_at_utc": now(), "updated_at_utc": now()})
|
|
|
|
current.update(
|
|
|
|
write_json(path, progress)
|
|
|
|
{
|
|
|
|
|
|
|
|
"status": "failed",
|
|
|
|
|
|
|
|
"percent": 100,
|
|
|
|
|
|
|
|
"stage": "Failed",
|
|
|
|
|
|
|
|
"message": str(exc),
|
|
|
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
|
|
|
"finished_at_utc": now(),
|
|
|
|
|
|
|
|
"updated_at_utc": now(),
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
write_json(path, current)
|
|
|
|
|
|
|
|
_log(traceback.format_exc())
|
|
|
|
finally:
|
|
|
|
finally:
|
|
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
|
|
try:
|
|
|
|
try:
|
|
|
@@ -272,28 +518,6 @@ def browser_worker(payload: dict, token: str, path: Path) -> None:
|
|
|
|
pass
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log(message: str) -> None:
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
DATA.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
with (DATA / "operator_agent.log").open("a", encoding="utf-8") as handle:
|
|
|
|
|
|
|
|
handle.write(f"[{now()}] {message}\n")
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _show_error(message: str) -> None:
|
|
|
|
|
|
|
|
_log("ERROR: " + message)
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
import tkinter as tk
|
|
|
|
|
|
|
|
from tkinter import messagebox
|
|
|
|
|
|
|
|
root = tk.Tk()
|
|
|
|
|
|
|
|
root.withdraw()
|
|
|
|
|
|
|
|
messagebox.showerror("ARRR GST Operator Agent", message)
|
|
|
|
|
|
|
|
root.destroy()
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def redeem_job(token: str) -> dict:
|
|
|
|
def redeem_job(token: str) -> dict:
|
|
|
|
cfg = read_config()
|
|
|
|
cfg = read_config()
|
|
|
|
erp = str(cfg.get("erp_base_url") or "").rstrip("/")
|
|
|
|
erp = str(cfg.get("erp_base_url") or "").rstrip("/")
|
|
|
@@ -313,8 +537,7 @@ def redeem_job(token: str) -> dict:
|
|
|
|
if not response.ok or not body.get("ok"):
|
|
|
|
if not response.ok or not body.get("ok"):
|
|
|
|
raise RuntimeError(body.get("error") or "ERP could not authorize this GST download.")
|
|
|
|
raise RuntimeError(body.get("error") or "ERP could not authorize this GST download.")
|
|
|
|
payload = dict(body.get("payload") or {})
|
|
|
|
payload = dict(body.get("payload") or {})
|
|
|
|
job_id = str(payload.get("jti") or "")
|
|
|
|
if not str(payload.get("jti") or ""):
|
|
|
|
if not job_id:
|
|
|
|
|
|
|
|
raise RuntimeError("ERP did not return a GST job id.")
|
|
|
|
raise RuntimeError("ERP did not return a GST job id.")
|
|
|
|
return payload
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
@@ -323,14 +546,20 @@ def run_job_foreground(token: str) -> None:
|
|
|
|
payload = redeem_job(token)
|
|
|
|
payload = redeem_job(token)
|
|
|
|
job_id = str(payload.get("jti") or "")
|
|
|
|
job_id = str(payload.get("jti") or "")
|
|
|
|
path = job_path(job_id)
|
|
|
|
path = job_path(job_id)
|
|
|
|
initial = {
|
|
|
|
write_json(
|
|
|
|
"status": "queued", "percent": 1, "stage": "Queued",
|
|
|
|
path,
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"status": "queued",
|
|
|
|
|
|
|
|
"percent": 1,
|
|
|
|
|
|
|
|
"stage": "Queued",
|
|
|
|
"message": "GST browser job launched from ARRR ERP.",
|
|
|
|
"message": "GST browser job launched from ARRR ERP.",
|
|
|
|
"job_id": job_id, "periods": payload.get("periods") or [],
|
|
|
|
"job_id": job_id,
|
|
|
|
|
|
|
|
"periods": payload.get("periods") or [],
|
|
|
|
"return_types": payload.get("return_types") or [],
|
|
|
|
"return_types": payload.get("return_types") or [],
|
|
|
|
"started_at_utc": now(), "updated_at_utc": now(),
|
|
|
|
"started_at_utc": now(),
|
|
|
|
}
|
|
|
|
"updated_at_utc": now(),
|
|
|
|
write_json(path, initial)
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
_log(f"Starting GST job {job_id}")
|
|
|
|
_log(f"Starting GST job {job_id}")
|
|
|
|
browser_worker(payload, token, path)
|
|
|
|
browser_worker(payload, token, path)
|
|
|
|
final = read_json(path)
|
|
|
|
final = read_json(path)
|
|
|
@@ -384,7 +613,6 @@ def main():
|
|
|
|
return
|
|
|
|
return
|
|
|
|
print(f"ARRR GST Operator Agent {VERSION}")
|
|
|
|
print(f"ARRR GST Operator Agent {VERSION}")
|
|
|
|
print("This agent is launched by the arrrgst:// Windows protocol from ARRR ERP.")
|
|
|
|
print("This agent is launched by the arrrgst:// Windows protocol from ARRR ERP.")
|
|
|
|
print("Run with --self-test to validate this installation.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
if __name__ == "__main__":
|
|
|
|