fix(login,download): login direct pe goapp, descarcare doar a documentului nou

- BT a mutat login-ul (09.2026): go.bancatransilvania.ro face 302 spre
  marketing; formularul e pe goapp.bancatransilvania.ro, acelasi tab, fara popup.
- Daca formularul nu apare: eroare explicita cu URL/titlu + screenshot/HTML.
- scripts/check_login_page.py: verifica pagina de login fara credentiale.
- Download: asteapta documentul nou din "Recente"; export gol -> [SKIP], nu timeout.
- Screenshot la eroare facut inainte de oprirea Playwright.
- opencode.json: agentii opencode nu pot citi .env.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DxPhZABSUf1RJXRWtwecqC
This commit is contained in:
2026-09-24 10:33:16 +03:00
parent ed9d3fc6d4
commit 3ac8ba68de
5 changed files with 396 additions and 27 deletions

View File

@@ -36,6 +36,16 @@ BTGO Scraper - Playwright automation for extracting account balances and transac
`TIMEOUT_2FA_SECONDS` and reports the wrong cause.
- **`networkidle` never fires**: BT pages poll trackers continuously. Use
`domcontentloaded` + explicit selector waits.
- **BT moved the login page (09.2026)**: `go.bancatransilvania.ro` now 302s to the
marketing site (no Login link); the form is served directly by
`goapp.bancatransilvania.ro/` (`-> /app/auth/login`), same tab, no popup. The
scraper sat broken with no clue: the only symptom was a bare `Timeout 15000ms`
from the old `a:has-text('Login')` wait. Now, when `input#user` does not show up
even after the reload retry, `login()` saves a screenshot + the page HTML in
`data/` and raises `Pagina de login BT s-a schimbat: URL final <url>, titlu
<titlu>`, so the real cause reaches the Telegram error message instead of dying
silently. When the bank moves the login again, run `scripts/check_login_page.py`
first (exit 0 = form intact, exit 1 = reason + evidence in `data/`).
## Coding Guidelines

View File

@@ -428,7 +428,10 @@ class BTGoScraper:
logging.info("Start BTGO Scraper")
logging.info("=" * 60)
with sync_playwright() as p:
# Nu `with`: iesirea din el opreste Playwright inainte de except,
# iar screenshot-ul din _handle_error pica ("Event loop is closed").
p = sync_playwright().start()
try:
# Viewport 1366x768: BT afiseaza un splash "redirect to store"
# sub ~1100px latime; 1366 e sigur peste prag. Fata de 1920x1080
# randeaza ~50% mai putini pixeli -> CPU mult mai mic la randarea
@@ -515,6 +518,11 @@ class BTGoScraper:
logging.info("✓ Scraper finalizat cu succes!")
logging.info("=" * 60)
return True
except Exception as e:
self._handle_error(e)
return False
finally:
p.stop()
except Exception as e:
self._handle_error(e)
@@ -523,13 +531,13 @@ class BTGoScraper:
def login(self):
"""Autentificare cu username si password"""
self._update_progress("Deschid pagina de login...")
logging.info("Navigare catre https://go.bancatransilvania.ro/")
# 'networkidle' nu se atinge niciodata: pagina face polling continuu pe
# Google Analytics / LinkedIn / Facebook pixels. Folosim 'domcontentloaded'
# si asteptam apoi explicit link-ul Login.
self.page.goto('https://go.bancatransilvania.ro/', wait_until='domcontentloaded', timeout=30000)
self.page.wait_for_selector("a:has-text('Login')", timeout=15000, state="visible")
logging.info("Pagina incarcata")
# Din 09.2026 go.bancatransilvania.ro redirecteaza (302) spre pagina de
# marketing, fara link Login. Formularul e servit direct de goapp
# (/ -> /app/auth/login), in acelasi tab, fara popup.
# 'networkidle' nu se atinge niciodata (pixeli de tracking) -> 'domcontentloaded'
# + asteptarea pe input#user de mai jos.
logging.info("Navigare catre https://goapp.bancatransilvania.ro/")
self.page.goto('https://goapp.bancatransilvania.ro/', wait_until='domcontentloaded', timeout=30000)
try:
# Cookie consent - asteapta si accepta (GDPR wrapper)
@@ -547,15 +555,7 @@ class BTGoScraper:
except:
pass
# Click pe butonul LOGIN - deschide tab nou
logging.info("Click pe butonul LOGIN...")
with self.page.expect_popup() as popup_info:
login_link = self.page.get_by_role("link", name="Login")
login_link.click()
# Preia referinta la tab-ul de login
self.login_page = popup_info.value
logging.info("Popup/tab login deschis")
self.login_page = self.page
# Asteapta ca Angular SPA sa randeze formularul de login.
# goapp.bancatransilvania.ro afiseaza intai un splash ("Aplicatia ta
@@ -582,10 +582,24 @@ class BTGoScraper:
"(SPA blocat pe splash) - reincarc popup-ul..."
)
self.login_page.reload(wait_until="domcontentloaded")
else:
raise
# la a 2-a incercare nu ridicam aici: iesim din bucla si
# raportam explicit mai jos (pagina de login s-a schimbat).
if not form_loaded:
raise PlaywrightTimeout("input#user nu a aparut nici dupa reload")
# BT a mutat formularul de login: salvam dovezi si spunem exact
# ce URL/titlu am vazut, in loc de un timeout anonim de selector.
self._debug_shot(self.login_page, "login_page_changed")
try:
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
html_path = Path(self.config.OUTPUT_DIR) / f"login_page_changed_{ts}.html"
html_path.write_text(self.login_page.content(), encoding='utf-8')
logging.error(f" [DIAG] HTML salvat: {html_path}")
except Exception as dump_err:
logging.warning(f" [DIAG] Nu am putut salva HTML-ul: {dump_err}")
raise Exception(
"Pagina de login BT s-a schimbat: "
f"URL final {self.login_page.url}, "
f"titlu {self.login_page.title()!r}"
)
logging.info("[OK] Formular login incarcat")
# Verifica GDPR cookies si pe popup
@@ -850,6 +864,9 @@ class BTGoScraper:
time.sleep(1)
logging.info(" Format CSV selectat")
# Marcheaza documentele existente in "Recente" (lista e cumulativa)
self._mark_existing_documents()
# Click pe butonul Genereaza
generate_btn = self.page.get_by_role("button", name="Generează")
generate_btn.click()
@@ -879,9 +896,9 @@ class BTGoScraper:
logging.info(" Dropdown conturi deschis")
# Debug: listeaza toate heading-urile vizibile din dropdown
visible_names = []
try:
headings = self.page.locator("fba-account-details h4, .account-name, h4").all()
visible_names = []
for h in headings[:10]: # Max 10
try:
if h.is_visible(timeout=500):
@@ -893,6 +910,9 @@ class BTGoScraper:
except:
pass
if visible_names and nume_cont not in visible_names:
logging.warning(f" [WARN] Contul '{nume_cont}' lipseste din lista dropdown: {visible_names}")
# Selecteaza contul dupa nume - strategii multiple
account_selected = False
@@ -974,6 +994,9 @@ class BTGoScraper:
time.sleep(2)
# Marcheaza documentele existente in "Recente"
self._mark_existing_documents()
# Click pe butonul Genereaza (CSV deja selectat de la primul cont)
generate_btn = self.page.get_by_role("button", name="Generează")
generate_btn.click()
@@ -987,9 +1010,67 @@ class BTGoScraper:
logging.error(f" [EROARE] Download cont ulterior {nume_cont}: {e}")
return None
# Texte afisate de BT cand exportul nu are continut (nu exista fisier de descarcat)
EMPTY_EXPORT_PATTERNS = (
"nu exist", "nu s-au gasit", "nu s-au găsit",
"fara tranzactii", "fără tranzacții",
)
def _mark_existing_documents(self):
"""Marcheaza documentele deja prezente in lista 'Recente'.
Lista este cumulativa, deci `wait_for_selector("fba-document-item")`
se satisfacea instant pe un document vechi. Marcand cele existente,
putem astepta exact documentul NOU generat.
"""
try:
n = self.page.eval_on_selector_all(
"fba-document-item",
"els => { els.forEach(e => e.setAttribute('data-seen', '1')); return els.length; }",
)
logging.info(f" Documente deja in 'Recente': {n}")
except Exception as e:
logging.debug(f" Nu am putut marca documentele existente: {e}")
def _dump_download_debug(self, nume_cont, tag):
"""La esec: screenshot + outerHTML al listei de documente si al dropdown-ului."""
safe = nume_cont.replace(' ', '_').replace('/', '_').replace('\\', '_')
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
self._debug_shot(self.page, f"download_{tag}_{safe}")
blocks = {
"documente": (
"() => { const it=[...document.querySelectorAll('fba-document-item')];"
" if(!it.length) return null; let p=it[0].parentElement;"
" while(p && !it.every(i=>p.contains(i))) p=p.parentElement;"
" return p ? p.outerHTML : null; }"
),
"dropdown": (
"() => { const o=document.querySelector('.cdk-overlay-container');"
" return o ? o.outerHTML : null; }"
),
}
for name, js in blocks.items():
try:
html = self.page.evaluate(js)
if not html:
logging.error(f" [DIAG] {name}: negasit in DOM")
continue
path = Path(self.config.OUTPUT_DIR) / f"debug_download_{tag}_{safe}_{name}_{stamp}.html"
path.write_text(html, encoding='utf-8')
logging.error(f" [DIAG] HTML {name} salvat: {path}")
except Exception as e:
logging.error(f" [DIAG] Nu am putut salva HTML {name}: {e}")
try:
texts = self.page.locator("fba-document-item").all_inner_texts()
logging.error(f" [DIAG] fba-document-item ({len(texts)}): {texts}")
except Exception:
pass
def _wait_and_download(self, nume_cont, iban, timeout=20000):
"""
Asteapta generarea fisierului si il descarca.
Asteapta generarea fisierului NOU si il descarca.
Args:
nume_cont: Numele contului (pentru filename)
@@ -1000,13 +1081,29 @@ class BTGoScraper:
Dict cu informatii despre fisierul descarcat sau None
"""
try:
# Asteapta sa apara fba-document-item (indica ca fisierul e gata)
self.page.wait_for_selector("fba-document-item", timeout=timeout)
logging.info(" Document generat - descarc...")
# Asteapta documentul NOU (nu unul vechi ramas in "Recente")
new_selector = "fba-document-item:not([data-seen])"
try:
self.page.wait_for_selector(new_selector, state="visible", timeout=timeout)
except PlaywrightTimeout:
logging.error(f" [EROARE] Niciun document nou generat pentru {nume_cont} ({timeout}ms)")
self._dump_download_debug(nume_cont, "no_new_document")
return None
# Click pe document item pentru a descarca
new_item = self.page.locator(new_selector).first
item_text = (new_item.inner_text() or "").strip()
first_line = item_text.splitlines()[0] if item_text else "(fara text)"
logging.info(f" Document nou: {first_line}")
# BT afiseaza un item de EROARE (fara buton de descarcare) cand
# exportul nu are tranzactii in perioada selectata.
if any(p in item_text.lower() for p in self.EMPTY_EXPORT_PATTERNS):
logging.warning(f" [SKIP] Export fara tranzactii pentru {nume_cont}: {item_text[:160]}")
return None
# Click pe documentul NOU pentru a descarca
with self.page.expect_download(timeout=timeout) as download_info:
download_btn = self.page.locator("fba-document-item svg, fba-document-item path").first
download_btn = new_item.locator("svg, path").first
download_btn.click()
download = download_info.value
@@ -1028,6 +1125,7 @@ class BTGoScraper:
except Exception as e:
logging.error(f" [EROARE] Download fisier: {e}")
self._dump_download_debug(nume_cont, "download_exceptie")
return None
def read_accounts(self):

View File

@@ -0,0 +1,72 @@
# Agenti opencode (DeepSeek V4.1 Flash) orchestrati din Claude Code
Verificat live 15.09.2026, opencode 1.18.31. Docs: https://opencode.ai/docs/server/
## Model
`opencode-go/deepseek-v4.1-flash` (abonament opencode go). In API: `{"providerID":"opencode-go","modelID":"deepseek-v4.1-flash"}`.
## Varianta 1: one-shot (fara comunicare pe parcurs)
```
opencode run -m opencode-go/deepseek-v4.1-flash --title <lane> "<sarcina>"
opencode run -s <sessionID> "<mesaj urmator>" # continua aceeasi sesiune dupa ce s-a oprit
```
## Varianta 2: server + HTTP (mesaje in timpul lucrului) — recomandat
Pornire (un server per proiect, pentru toate lane-urile lui, in directorul proiectului):
```
opencode serve --port <port proiect> # 127.0.0.1, fundal
```
**Port fix per proiect.** Proiectele ROA ruleaza in paralel; pe acelasi port, oprirea serverului dintr-un proiect
opreste lane-urile celuilalt si le poate muta directorul de lucru (verificat 15.09.2026: lane ROACONT ajuns in ROAFACTURARE).
| Proiect | Port |
|---|---|
| ROAFACTURARE | 4096 |
| ROACONT | 4196 |
| ROAIMOB | 4296 |
| btgo-playwright | 4396 |
Proiect nou: urmatorul port liber din tabel (+100), trecut aici inainte de pornire. In API trimite si `?directory=<radacina proiectului>`.
| Actiune | Apel |
|---|---|
| Sesiune noua | `POST /session` `{title}` → `id` |
| Sarcina (nu asteapta) | `POST /session/:id/prompt_async` `{model, parts:[{type:"text",text}]}` |
| Mesaj in timpul lucrului | acelasi `prompt_async` pe sesiunea ocupata |
| Stare | `GET /session/status` → `{id:{type:"busy"}}`; sesiunea lipseste cand e libera |
| Rezultat | `GET /session/:id/message` (parts cu `type=text`) |
| Oprire | `POST /session/:id/abort` |
| Evenimente live | `GET /event` (SSE) |
| Interfata vizuala | `opencode attach http://127.0.0.1:<port proiect>` (Marius poate urmari sesiunile) |
**Comportament verificat:** mesajul trimis cat agentul e `busy` intra in coada si e preluat la
pasul urmator (dupa apelul de tool curent), in aceeasi sesiune; agentul l-a respectat.
Nu intrerupe un tool aflat in executie. Pentru oprire imediata: `abort`, apoi mesaj nou.
## Prompt initial (obligatoriu)
opencode incarca singur `CLAUDE.md` din proiect si `~/.claude/CLAUDE.md`, dar acolo scrie cum sa
orchestreze Claude (delegare, gstack), nu cum sa execute. De aceea fiecare sesiune primeste
`COMUN\docs\opencode_prompt_initial.md` in campul `system` (verificat: agentul il respecta):
```powershell
$sys = [IO.File]::ReadAllText('D:\ROA\<PROIECT>\COMUN\docs\opencode_prompt_initial.md')
$b = @{model=$m; system=$sys; parts=@(@{type='text';text="Lane: <nume>. <sarcina>"})} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post "$u/session/$sid/prompt_async" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($b))
```
Cu `opencode run`, fara server: pui promptul ca fisier atasat, `-f COMUN\docs\opencode_prompt_initial.md`.
Promptul impune: rol de executant, citirea `reguli_lucru.md`, interdictiile (commit, write-back,
binare, `sed -i`), censul cp1250 + CRLF, raportul `docs\raport_<lane>.md` si ultimul mesaj
`GATA <lane>` / `BLOCAT <lane>: motiv`.
## Permisiuni
Fara configurare, agentul se opreste la fiecare acces in afara proiectului (scratchpad, `D:\ROA\UTIL`) si asteapta
aprobare. Configurarea e globala, pentru toate proiectele, in `%USERPROFILE%\.config\opencode\opencode.jsonc`:
```jsonc
"permission": { "edit": "allow", "bash": "allow", "webfetch": "allow", "external_directory": "allow" }
```
Se aplica la pornirea serverului (`opencode serve`); un server deja pornit trebuie repornit.
O cerere ramasa in asteptare se aproba prin API: `GET /permission` → `POST /session/:id/permissions/:permissionID` `{"response":"always"}`.
## Reguli pentru lane-uri
- Sarcina contine numele lane-ului, fisierele exacte si criteriul de terminare.
- Orchestratorul verifica dupa fiecare lane: diff, octeti cp1252, CRLF, teste.
- Dupa lucru: opreste serverul (PID-ul real e cel care asculta pe port, nu cel din `Start-Process`):
`Stop-Process -Id (Get-NetTCPConnection -LocalPort <port proiect> -State Listen).OwningProcess` - doar portul propriu.

7
opencode.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"read": { "*.env": "deny", "*": "allow" },
"bash": { "*.env*": "deny", "*": "allow" }
}
}

182
scripts/check_login_page.py Normal file
View File

@@ -0,0 +1,182 @@
"""
Verifica daca pagina de login BT e inca la fel (campuri user/parola/submit).
Fara credentiale, fara .env: doar deschide pagina de login ca scraper-ul,
o observa si iese cu cod 0 daca formularul e intact, altfel 1 + motivul.
Nu completeaza campuri si nu submite nimic.
Chromium e lansat identic cu btgo_scraper.py (noul mod headless
--headless=new + UA de Chrome normal), altfel WAF-ul Akamai al BT raspunde
cu Access Denied. Nu folosim networkidle (nu se atinge niciodata pe site-ul
BT) -> domcontentloaded + asteptari pe selector.
Nota: lansarea browserului e duplicata din btgo_scraper.run() pentru ca
acolo e inline si btgo_scraper importa config (care citeste .env). Daca
lansarea va fi extrasa intr-un helper fara config, scriptul il poate importa.
"""
import sys
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
# Aceleasi intrari ca in btgo_scraper.login() (ordinea conteaza).
LOGIN_ENTRY_URLS = [
"https://goapp.bancatransilvania.ro/",
"https://go.bancatransilvania.ro/",
]
USER_SELECTORS = ["input#user", "input[placeholder='ID logare']"]
PASSWORD_SELECTORS = ["input#password", "input[type='password']"]
SUBMIT_SELECTORS = [
"input[type='submit']",
"button[type='submit']",
"input[value*='Autentific']",
]
DATA_DIR = Path("data")
def launch(p):
"""Lanseaza Chromium exact ca btgo_scraper.run() (WAF-safe)."""
browser = p.chromium.launch(
headless=False,
slow_mo=0,
args=[
"--window-size=1366,768",
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-background-networking",
"--disable-renderer-backgrounding",
"--disable-features=Translate",
"--headless=new",
],
)
context = browser.new_context(
viewport={"width": 1366, "height": 768},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/130.0.0.0 Safari/537.36"
),
)
return browser, context
def first_visible(page, selectors, timeout=1000):
"""Primul selector din lista care are un element vizibil, sau None."""
for sel in selectors:
try:
el = page.locator(sel).first
if el.count() and el.is_visible(timeout=timeout):
return sel
except Exception:
continue
return None
def dump_diag(page):
"""Salveaza HTML + screenshot pentru diagnostic. Returneaza caile scrise."""
written = []
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
try:
DATA_DIR.mkdir(exist_ok=True)
except Exception:
return written
try:
html_path = DATA_DIR / f"check_login_page_{ts}.html"
html_path.write_text(page.content(), encoding="utf-8")
written.append(html_path)
except Exception:
pass
try:
shot_path = DATA_DIR / f"check_login_page_{ts}.png"
page.screenshot(path=str(shot_path))
written.append(shot_path)
except Exception:
pass
return written
def check_page(page, url):
"""Navigheaza la `url` si inspecteaza formularul. Returneaza un dict."""
info = {
"ok": False,
"url": url,
"final_url": None,
"title": None,
"user": None,
"password": None,
"submit": None,
"error": None,
}
print(f"[check] {url}")
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
except Exception as e:
info["error"] = f"goto a esuat: {e}"
print(f" [WARN] {info['error']}")
return info
try:
page.wait_for_selector(
", ".join(USER_SELECTORS), timeout=60000, state="visible"
)
except PlaywrightTimeout:
info["final_url"] = page.url
info["title"] = page.title()
info["error"] = "formularul de login (input#user) nu a aparut"
print(f" [WARN] {info['error']} (URL final: {info['final_url']})")
return info
info["final_url"] = page.url
info["title"] = page.title()
info["user"] = first_visible(page, USER_SELECTORS)
info["password"] = first_visible(page, PASSWORD_SELECTORS)
info["submit"] = first_visible(page, SUBMIT_SELECTORS)
info["ok"] = bool(info["user"] and info["password"] and info["submit"])
print(f" URL final: {info['final_url']}")
print(f" titlu: {info['title']!r}")
print(f" user: {info['user']!r}")
print(f" parola: {info['password']!r}")
print(f" submit: {info['submit']!r}")
return info
def main():
with sync_playwright() as p:
browser, context = launch(p)
try:
page = context.new_page()
last = None
for url in LOGIN_ENTRY_URLS:
info = check_page(page, url)
if info["ok"]:
print("[OK] Pagina de login e intacta "
"(campuri user/parola/submit gasite).")
return 0
last = info
written = dump_diag(page)
missing = [k for k in ("user", "password", "submit")
if not last.get(k)]
print("[FAIL] Pagina de login BT s-a schimbat.")
print(f" URL final: {last.get('final_url')}, "
f"titlu: {last.get('title')!r}")
if last.get("error"):
print(f" Motiv: {last['error']}")
if missing:
print(f" Campuri lipsa: {', '.join(missing)}")
for path in written:
print(f" Dovezi: {path}")
return 1
finally:
browser.close()
if __name__ == "__main__":
sys.exit(main())