Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
553 lines
21 KiB
Python
553 lines
21 KiB
Python
"""
|
|
SARS Retun History Period — extracts the EMP201 payment breakdown for one
|
|
or more periods, using agent-browser (Rust CLI on CDP) for the parts of
|
|
the flow that the cross-origin iframe blocks Playwright from doing.
|
|
|
|
Inputs:
|
|
--company Company name as stored in PocketBase (e.g. "RJ BULK SUPPLIES")
|
|
--return-type Return type (default EMP201). Aliases also accepted:
|
|
PTAX, IRP6, VAT, "Value Added Tax", ITR14, ITR12T, ITR12EI, ITR12.
|
|
--periods One period "202606" or a contiguous range "202603-202605".
|
|
|
|
Output (printed to stdout):
|
|
Company name
|
|
PAYE no
|
|
Payment Period <YYYYMM>
|
|
PAYE Payable
|
|
SDL Payable
|
|
UIF Payable
|
|
Total Payable
|
|
Payment Reference No.
|
|
... (one block per period; for ranges, blocks repeat)
|
|
|
|
Implementation notes:
|
|
* Login + Portfolio + Taxpayer + Returns History nav + period clicks
|
|
all driven via agent-browser subprocess.
|
|
* The agent-browser skill's `find` command and CSS-selector-based
|
|
`click` are unreliable on the SARS app (Playwright-only syntax or
|
|
selectors that don't match what SARS renders). The reliable path
|
|
is: take a `snapshot -i`, parse the @ref for the element we need,
|
|
then `fill @ref` / `click @ref`. This is what we do here.
|
|
* Password and Portfolio are looked up in PocketBase via the same
|
|
helpers SARS_login_Select_Company exposes.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
# Make the sibling frozen module importable so we can reuse find_company /
|
|
# determine_portfolio / fetch_dtc_password.
|
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
PROJECT_ROOT = SCRIPTS_DIR.parent
|
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
|
|
|
|
# ── agent-browser executable discovery ─────────────────────────────
|
|
|
|
def _find_agent_browser_exe() -> str:
|
|
"""Locate the agent-browser executable.
|
|
|
|
Python's subprocess.run on Windows does NOT do PowerShell's PATHEXT
|
|
resolution, so passing ["agent-browser", ...] fails with
|
|
FileNotFoundError even though `agent-browser` works from a normal
|
|
PowerShell. We probe for the .cmd / .exe / .ps1 shims in PATH and
|
|
fall back to the standard npm global install path.
|
|
"""
|
|
for name in ("agent-browser.cmd", "agent-browser.exe",
|
|
"agent-browser.bat", "agent-browser"):
|
|
path = shutil.which(name)
|
|
if path:
|
|
return path
|
|
npm_dir = Path(os.path.expanduser("~")) / "AppData" / "Roaming" / "npm"
|
|
for name in ("agent-browser.cmd", "agent-browser.exe", "agent-browser.ps1"):
|
|
candidate = npm_dir / name
|
|
if candidate.exists():
|
|
return str(candidate)
|
|
raise RuntimeError(
|
|
"Could not find agent-browser. Tried: agent-browser.cmd, "
|
|
"agent-browser.exe, agent-browser.bat, agent-browser, and the "
|
|
"npm global install dir. Install with: npm i -g agent-browser"
|
|
)
|
|
|
|
|
|
AGENT_BROWSER_EXE = _find_agent_browser_exe()
|
|
|
|
|
|
def run_ab(*args, check=True):
|
|
"""Run an agent-browser command, return its stdout (stripped).
|
|
|
|
NOTE: do NOT use `capture_output=True` / `stdout=PIPE`. The
|
|
agent-browser .exe does TTY detection on launch; when stdio is
|
|
piped (no TTY) it enters an interactive wait and never returns.
|
|
Verified by direct comparison: capture_output=False -> 0.8s,
|
|
capture_output=True -> hangs indefinitely. We redirect stdout to a
|
|
temp file instead so the child still writes to a file (not a pipe)
|
|
while the parent can read the result back.
|
|
"""
|
|
import tempfile
|
|
tmp = tempfile.NamedTemporaryFile(
|
|
mode="w", delete=False, encoding="utf-8", errors="replace",
|
|
prefix="ab_", suffix=".out",
|
|
)
|
|
tmp_path = tmp.name
|
|
tmp.close()
|
|
try:
|
|
result = subprocess.run(
|
|
[AGENT_BROWSER_EXE, *args],
|
|
stdout=open(tmp_path, "w", encoding="utf-8", errors="replace"),
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
with open(tmp_path, "r", encoding="utf-8", errors="replace") as f:
|
|
out = f.read().strip()
|
|
finally:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
if check and result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"agent-browser {args!r} failed (rc={result.returncode}): {out}"
|
|
)
|
|
return out
|
|
|
|
|
|
# ── Snapshot parsing ──────────────────────────────────────────────
|
|
|
|
def snapshot() -> str:
|
|
"""Return the current accessibility-tree snapshot (interactive only)."""
|
|
return run_ab("snapshot", "-i")
|
|
|
|
|
|
def find_ref(snap: str, role: str, name: Optional[str] = None) -> Optional[str]:
|
|
"""Find the @ref for an element in the snapshot.
|
|
|
|
`role` is the accessibility role (e.g. "textbox", "button", "link").
|
|
`name` is the accessible name to match (e.g. "Username", "Next").
|
|
Returns the @ref string (e.g. "e9") or None if not found.
|
|
|
|
The snapshot format is:
|
|
- textbox "Username" [required, ref=e9]
|
|
- button "Next" [ref=e7]
|
|
- heading "Welcome" [level=1, ref=e3]
|
|
Note: the `ref` is the LAST attribute inside the `[...]` block, not
|
|
the whole block. So we match `,\\s*ref=eN]` (or `ref=eN]` at the
|
|
start of an empty attrs list).
|
|
"""
|
|
if name is None:
|
|
pattern = rf"\b{re.escape(role)}\b[^\n]*?[,]?\s*ref=(e\d+)\]"
|
|
else:
|
|
# Match: <role> ... "<name>" ... , ref=eN]
|
|
pattern = (
|
|
rf"\b{re.escape(role)}\b[^\n]*?['\"]({re.escape(name)})['\"][^\n]*?"
|
|
rf"[,]?\s*ref=(e\d+)\]"
|
|
)
|
|
m = re.search(pattern, snap, re.MULTILINE)
|
|
if m:
|
|
return m.group(2) if m.lastindex and m.lastindex >= 2 else m.group(1)
|
|
return None
|
|
|
|
|
|
def click_text(target: str) -> None:
|
|
"""Click a link or button whose accessible name is `target`."""
|
|
snap = snapshot()
|
|
# Try link first, then button
|
|
ref = find_ref(snap, "link", target) or find_ref(snap, "button", target)
|
|
if not ref:
|
|
raise RuntimeError(f"Could not find clickable element with text {target!r}")
|
|
run_ab("click", f"@{ref}")
|
|
|
|
|
|
# ── Period parsing ────────────────────────────────────────────────
|
|
|
|
def expand_periods(spec: str) -> List[str]:
|
|
"""Expand a period spec into a list of YYYYMM strings.
|
|
|
|
"202606" -> ["202606"]
|
|
"202603-202605" -> ["202603", "202604", "202605"]
|
|
|
|
Handles year wrap (e.g. "202611-202602" -> 202611, 202612, 202601, 202602).
|
|
"""
|
|
spec = spec.strip()
|
|
if "-" in spec:
|
|
start, end = spec.split("-", 1)
|
|
start, end = start.strip(), end.strip()
|
|
if (len(start) != 6 or len(end) != 6
|
|
or not start.isdigit() or not end.isdigit()):
|
|
raise ValueError(f"Invalid period range: {spec!r}")
|
|
si, ei = int(start), int(end)
|
|
if si > ei:
|
|
raise ValueError(f"Start period {start} is after end period {end}")
|
|
out = []
|
|
i = si
|
|
while i <= ei:
|
|
out.append(f"{i:06d}")
|
|
y, m = divmod(i, 100)
|
|
m += 1
|
|
if m > 12:
|
|
m = 1
|
|
y += 1
|
|
i = y * 100 + m
|
|
return out
|
|
if len(spec) != 6 or not spec.isdigit():
|
|
raise ValueError(f"Invalid period: {spec!r}")
|
|
return [spec]
|
|
|
|
|
|
# ── PocketBase lookups (reused from the frozen module) ─────────────
|
|
|
|
def fetch_company_and_portfolio(company: str):
|
|
from SARS_login_Select_Company import find_company, determine_portfolio
|
|
record = find_company(company)
|
|
name = record.get("enterprise_name") or company
|
|
portfolio = determine_portfolio(record)
|
|
return name, portfolio
|
|
|
|
|
|
def fetch_dtc_password() -> str:
|
|
from SARS_login_Select_Company import fetch_dtc_password as _fetch
|
|
return _fetch()
|
|
|
|
|
|
# ── Return-type alias resolution ──────────────────────────────────
|
|
|
|
SUBITEM_LABELS = {
|
|
# NB: the agent-browser snapshot output renders the page's curly
|
|
# apostrophe (U+2019) as an ASCII one (U+0027). Match against what
|
|
# the snapshot actually shows, not the page's DOM.
|
|
"EMP201": "Employee's Tax (EMP201)",
|
|
"EMP501": "Employee s Tax (EMP501)", # NB: no apostrophe at all here
|
|
"Income Tax": "Income Tax (ITR14/ITR12T/IT12EI)",
|
|
"Provisional Tax": "Provisional Tax (IRP6)",
|
|
"VAT201": "Value Added Tax (VAT201)",
|
|
}
|
|
|
|
ALIASES = {
|
|
"EMP201": "EMP201", "EMP 201": "EMP201",
|
|
"EMP501": "EMP501", "EMP 501": "EMP501",
|
|
"INCOME TAX": "Income Tax",
|
|
"ITR14": "Income Tax", "ITR12T": "Income Tax",
|
|
"ITR12EI": "Income Tax", "ITR12": "Income Tax",
|
|
"PTAX": "Provisional Tax", "IRP6": "Provisional Tax",
|
|
"PROVISIONAL TAX": "Provisional Tax",
|
|
"VAT": "VAT201", "VAT201": "VAT201", "VALUE ADDED TAX": "VAT201",
|
|
}
|
|
|
|
def resolve_return_type(user_input: str) -> str:
|
|
raw = user_input.strip()
|
|
if raw in SUBITEM_LABELS:
|
|
return raw
|
|
up = raw.upper()
|
|
if up in SUBITEM_LABELS:
|
|
return up
|
|
if up in ALIASES:
|
|
return ALIASES[up]
|
|
raise ValueError(
|
|
f"Unknown return type {user_input!r}. "
|
|
f"Valid canonical names: {sorted(SUBITEM_LABELS.keys())}. "
|
|
f"Aliases: PTAX, IRP6, VAT, 'Value Added Tax', ITR14, ITR12T, ITR12EI, ITR12."
|
|
)
|
|
|
|
|
|
# ── InputXML parser ──────────────────────────────────────────────
|
|
|
|
def parse_input_xml(xml_str: str) -> dict:
|
|
"""Extract the 8 fields the user wants from the EMP201 InputXML string."""
|
|
xml_str = (
|
|
xml_str.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace(""", '"')
|
|
.replace("&", "&")
|
|
)
|
|
root = ET.fromstring(xml_str)
|
|
employer = root.find("EMPLOYER_DETAILS")
|
|
payment = root.find("PAYMENT_DETAILS")
|
|
alloc = payment.find("PAYMENT_ALLOCATION") if payment is not None else None
|
|
|
|
def txt(parent, tag):
|
|
if parent is None:
|
|
return ""
|
|
el = parent.find(tag)
|
|
return (el.text or "").strip() if el is not None else ""
|
|
|
|
return {
|
|
"company_name": txt(employer, "TRADING_NAME"),
|
|
"paye_no": txt(employer, "PAYE_REF_NO"),
|
|
"payment_period": txt(alloc, "PAYMENT_PERIOD"),
|
|
"paye_payable": txt(alloc, "PAYE_AMT"),
|
|
"sdl_payable": txt(alloc, "SDL_AMT"),
|
|
"uif_payable": txt(alloc, "UIF_AMT"),
|
|
"total_payable": txt(payment, "TOTAL_PAYMENT"),
|
|
"payment_ref_no": txt(payment, "PAYMENT_REF_NO"),
|
|
}
|
|
|
|
|
|
# ── agent-browser flow ───────────────────────────────────────────
|
|
|
|
def login(username: str, password: str) -> None:
|
|
"""Log in to SARS eFiling via agent-browser using snapshot + refs."""
|
|
print(" [login] close --all ...", flush=True)
|
|
run_ab("close", "--all")
|
|
# NOTE: `open <url>` waits for network-idle, which the SARS login page
|
|
# never reaches (its AI assistant chat region keeps a WebSocket open
|
|
# forever). We split into `open` (no URL, instant launch on about:blank)
|
|
# + `navigate <url>`, which returns as soon as the page is interactive.
|
|
print(" [login] open (no url) ...", flush=True)
|
|
run_ab("open")
|
|
print(" [login] navigate to login page ...", flush=True)
|
|
run_ab("navigate", "https://secure.sarsefiling.co.za/app/login")
|
|
print(" [login] waiting 3.5s for page to render ...", flush=True)
|
|
run_ab("wait", "3500")
|
|
|
|
# Username + Next
|
|
print(" [login] snapshot for username page ...", flush=True)
|
|
snap = snapshot()
|
|
user_ref = find_ref(snap, "textbox", "Username")
|
|
if not user_ref:
|
|
raise RuntimeError("Username textbox not found in snapshot")
|
|
print(f" [login] fill username @e{user_ref} ...", flush=True)
|
|
run_ab("fill", f"@{user_ref}", username)
|
|
next_ref = find_ref(snap, "button", "Next")
|
|
if not next_ref:
|
|
raise RuntimeError("Next button not found in snapshot")
|
|
print(f" [login] click Next @e{next_ref} ...", flush=True)
|
|
run_ab("click", f"@{next_ref}")
|
|
print(" [login] waiting 3s for password page ...", flush=True)
|
|
run_ab("wait", "3000")
|
|
|
|
# Password + Login
|
|
print(" [login] snapshot for password page ...", flush=True)
|
|
snap = snapshot()
|
|
pw_ref = find_ref(snap, "textbox", "Password")
|
|
if not pw_ref:
|
|
raise RuntimeError("Password textbox not found in snapshot")
|
|
print(f" [login] fill password @e{pw_ref} ...", flush=True)
|
|
run_ab("fill", f"@{pw_ref}", password)
|
|
login_ref = find_ref(snap, "button", "Login")
|
|
if not login_ref:
|
|
raise RuntimeError("Login button not found in snapshot")
|
|
print(f" [login] click Login @e{login_ref} ...", flush=True)
|
|
run_ab("click", f"@{login_ref}")
|
|
print(" [login] waiting 4s for dashboard ...", flush=True)
|
|
run_ab("wait", "4000")
|
|
print(" [login] done.", flush=True)
|
|
|
|
|
|
def select_portfolio_taxpayer(portfolio: str, taxpayer: str) -> None:
|
|
"""Click the Portfolio combobox, pick `portfolio`, then Taxpayer, pick `taxpayer`."""
|
|
# The dashboard uses Angular Material mat-select. We open the panel
|
|
# by clicking the combobox trigger, then pick the option by text via
|
|
# a JavaScript eval (more reliable than find-by-text on virtual-
|
|
# scrolled lists).
|
|
# NOTE: each eval runs in the same global scope, so `const`/`let`
|
|
# identifiers cannot be redeclared. Use unique names (optsA, optsB)
|
|
# per call.
|
|
print(" [dashboard] select Portfolio ...", flush=True)
|
|
run_ab("eval", "document.querySelector('mat-select[name=\"taxuser\"]').click()")
|
|
run_ab("wait", "1500")
|
|
run_ab("eval", f"""
|
|
const optsA = document.querySelectorAll('.cdk-overlay-pane mat-option');
|
|
for (const o of optsA) {{
|
|
if (o.textContent.trim() === {portfolio!r}) {{ o.click(); break; }}
|
|
}}
|
|
""")
|
|
run_ab("wait", "2500")
|
|
|
|
print(" [dashboard] select Taxpayer ...", flush=True)
|
|
run_ab("eval", "document.querySelector('mat-select[name=\"TaxPayer\"]').click()")
|
|
run_ab("wait", "1500")
|
|
run_ab("eval", f"""
|
|
const optsB = document.querySelectorAll('.cdk-overlay-pane mat-option');
|
|
for (const o of optsB) {{
|
|
if (o.textContent.trim() === {taxpayer!r}) {{ o.click(); break; }}
|
|
}}
|
|
""")
|
|
run_ab("wait", "2500")
|
|
print(" [dashboard] done.", flush=True)
|
|
|
|
|
|
def navigate_to_returns_history_subitem(return_type: str) -> None:
|
|
"""Click Returns History parent, then the sub-item for the return type."""
|
|
canonical = resolve_return_type(return_type)
|
|
label = SUBITEM_LABELS[canonical]
|
|
|
|
# Parent: link with exact text "Returns History"
|
|
click_text("Returns History")
|
|
run_ab("wait", "2500")
|
|
|
|
# Sub-item: link with exact text == subitem label
|
|
click_text(label)
|
|
run_ab("wait", "3500")
|
|
|
|
|
|
def open_period_and_extract(period: str) -> Optional[dict]:
|
|
"""Click Open for `period`, click the return-type link in the work page,
|
|
extract InputXML, and return the parsed fields dict (or None on failure)."""
|
|
# 1. Click Open for the period. The Open link has no text but its
|
|
# href contains Period=YYYYMM, so we use JS to find and click.
|
|
run_ab("eval", f"""
|
|
const links = document.querySelectorAll('a');
|
|
for (const a of links) {{
|
|
if ((a.getAttribute('href') || '').includes('Period={period}')) {{
|
|
a.click();
|
|
break;
|
|
}}
|
|
}}
|
|
""")
|
|
run_ab("wait", "3500")
|
|
|
|
# 2. Click the return-type link inside the work page's small table row.
|
|
# The work page is inside a cross-origin iframe; agent-browser
|
|
# handles that via CDP, so the click reaches the right frame.
|
|
run_ab("eval", """
|
|
const rows = document.querySelectorAll('table tr');
|
|
for (const row of rows) {
|
|
const link = row.querySelector('a');
|
|
if (link && row.textContent.includes('EMP201')) {
|
|
link.click();
|
|
break;
|
|
}
|
|
}
|
|
""")
|
|
run_ab("wait", "3500")
|
|
|
|
# 3. Read the hidden InputXML the form embeds.
|
|
xml = run_ab(
|
|
"eval",
|
|
"(document.querySelector('input[name=\"InputXML\"]') || {}).value || ''",
|
|
)
|
|
if not xml:
|
|
return None
|
|
return parse_input_xml(xml)
|
|
|
|
|
|
def go_back_to_period_list() -> None:
|
|
"""Navigate back from the work page to the period list."""
|
|
run_ab("eval", "history.back()")
|
|
run_ab("wait", "3500")
|
|
|
|
|
|
# ── Report formatting ─────────────────────────────────────────────
|
|
|
|
def print_report(periods_with_data: List[tuple]) -> None:
|
|
"""Print the report in the user's requested format."""
|
|
first_data = next((d for _, d in periods_with_data if d is not None), {})
|
|
print()
|
|
print("=" * 60)
|
|
print("REPORT")
|
|
print("=" * 60)
|
|
print(f"Company name {first_data.get('company_name', '')}")
|
|
print(f"PAYE no {first_data.get('paye_no', '')}")
|
|
print()
|
|
for period, data in periods_with_data:
|
|
if data is None:
|
|
print(f"--- Period {period} ---")
|
|
print(" (no data — Open link or work page not found)")
|
|
print()
|
|
continue
|
|
print(f"--- Payment Period {data.get('payment_period') or period} ---")
|
|
print(f"PAYE Payable {data.get('paye_payable', '')}")
|
|
print(f"SDL Payable {data.get('sdl_payable', '')}")
|
|
print(f"UIF Payable {data.get('uif_payable', '')}")
|
|
print(f"Total Payable {data.get('total_payable', '')}")
|
|
print(f"Payment Reference No. {data.get('payment_ref_no', '')}")
|
|
print()
|
|
|
|
|
|
# ── CLI ───────────────────────────────────────────────────────────
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description=(
|
|
"Log into SARS eFiling as Dynamic Tax Consultants, navigate "
|
|
"Returns History > <return type>, and extract the payment "
|
|
"breakdown for one or more periods."
|
|
)
|
|
)
|
|
ap.add_argument("--company", required=True,
|
|
help="Company name (matched in PocketBase enterprise_name).")
|
|
ap.add_argument("--return-type", default="EMP201",
|
|
help="Return type (default EMP201). Aliases accepted: "
|
|
"PTAX, IRP6, VAT, 'Value Added Tax', ITR14, ITR12T, "
|
|
"ITR12EI, ITR12, 'Income tax', 'Provisional tax'.")
|
|
ap.add_argument("--periods", required=True,
|
|
help='One period "202606" or a contiguous range "202603-202605".')
|
|
args = ap.parse_args()
|
|
|
|
# --- Validate and resolve inputs ---
|
|
try:
|
|
periods = expand_periods(args.periods)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
return 2
|
|
try:
|
|
canonical_type = resolve_return_type(args.return_type)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
return 2
|
|
|
|
# --- Look up company + DTC password ---
|
|
try:
|
|
company_name, portfolio = fetch_company_and_portfolio(args.company)
|
|
except Exception as e:
|
|
print(f"Error looking up company in PocketBase: {e}")
|
|
return 1
|
|
try:
|
|
password = fetch_dtc_password()
|
|
except Exception as e:
|
|
print(f"Error fetching DTC password from PocketBase: {e}")
|
|
return 1
|
|
|
|
print(f"Company: {company_name}")
|
|
print(f"Portfolio: {portfolio}")
|
|
print(f"Taxpayer: {company_name}")
|
|
print(f"Return: {canonical_type} (sidebar: {SUBITEM_LABELS[canonical_type]!r})")
|
|
print(f"Periods: {periods}")
|
|
print()
|
|
|
|
# --- Drive the flow via agent-browser ---
|
|
try:
|
|
login("sjcoetzee1092", password)
|
|
select_portfolio_taxpayer(portfolio, company_name)
|
|
navigate_to_returns_history_subitem(canonical_type)
|
|
except Exception as e:
|
|
print(f"Error during navigation: {e}")
|
|
run_ab("close", "--all", check=False)
|
|
return 1
|
|
|
|
# --- Extract one period at a time ---
|
|
results: List[tuple] = []
|
|
for i, period in enumerate(periods):
|
|
print(f"[{i+1}/{len(periods)}] Extracting period {period} ...", flush=True)
|
|
try:
|
|
data = open_period_and_extract(period)
|
|
except Exception as e:
|
|
print(f" Error: {e}", flush=True)
|
|
data = None
|
|
results.append((period, data))
|
|
if i < len(periods) - 1:
|
|
try:
|
|
go_back_to_period_list()
|
|
except Exception as e:
|
|
print(f" Warning: could not go back to period list: {e}",
|
|
flush=True)
|
|
|
|
# --- Close the browser ---
|
|
run_ab("close", "--all", check=False)
|
|
|
|
# --- Report ---
|
|
print_report(results)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|