diff --git a/CLAUDE.md b/CLAUDE.md index d9d9e08..39b6a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 , 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 diff --git a/btgo_scraper.py b/btgo_scraper.py index 324e8a8..37916f2 100644 --- a/btgo_scraper.py +++ b/btgo_scraper.py @@ -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): diff --git a/docs/opencode_agenti_orchestrare.md b/docs/opencode_agenti_orchestrare.md new file mode 100644 index 0000000..d0988fa --- /dev/null +++ b/docs/opencode_agenti_orchestrare.md @@ -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 "" +opencode run -s "" # 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 # 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=`. +| 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:` (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\\COMUN\docs\opencode_prompt_initial.md') +$b = @{model=$m; system=$sys; parts=@(@{type='text';text="Lane: . "})} | 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_.md` si ultimul mesaj +`GATA ` / `BLOCAT : 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 -State Listen).OwningProcess` - doar portul propriu. diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..416eb47 --- /dev/null +++ b/opencode.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": { "*.env": "deny", "*": "allow" }, + "bash": { "*.env*": "deny", "*": "allow" } + } +} diff --git a/scripts/check_login_page.py b/scripts/check_login_page.py new file mode 100644 index 0000000..29b1fd4 --- /dev/null +++ b/scripts/check_login_page.py @@ -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())