Compare commits
10 Commits
c2ca401a26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 306aa55907 | |||
| 0fff07c55b | |||
| e49e653e12 | |||
| 58399e25fb | |||
| 91021fa530 | |||
| 548526fdde | |||
| e2ec15939c | |||
| 7e8dadcbdc | |||
| 1e20334b3d | |||
| 4e58e663e9 |
@@ -22,11 +22,12 @@ SMTP_PASSWORD=your-app-password
|
||||
EMAIL_FROM=your-email@gmail.com
|
||||
EMAIL_TO=mmarius28@gmail.com
|
||||
|
||||
# Telegram
|
||||
# Telegram Trigger Bot (pentru declanșare remote prin Telegram)
|
||||
TELEGRAM_ENABLED=false
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
# Telegram Trigger Bot (pentru declanșare remote prin Telegram)
|
||||
TELEGRAM_ALLOWED_USER_IDS=123456789,987654321 # User IDs autorizați (goală = toți)
|
||||
TELEGRAM_ALLOWED_USER_IDS=123456789,987654321 # User IDs autorizați individual (opțional)
|
||||
# TELEGRAM_CHAT_ID = grup autorizat (orice membru poate folosi bot-ul în DM/grup)
|
||||
# Dacă ambele sunt goale = permite oricui
|
||||
TELEGRAM_POLL_TIMEOUT=60 # Long polling timeout în secunde (30-90 recomandat)
|
||||
|
||||
76
CLAUDE.md
76
CLAUDE.md
@@ -4,11 +4,15 @@ This file provides guidance to Claude Code when working with code in this reposi
|
||||
|
||||
## Project Overview
|
||||
|
||||
BTGO Scraper - Playwright automation for extracting account balances and transaction CSVs from Banca Transilvania George (btgo.ro).
|
||||
BTGO Scraper - Playwright automation for extracting account balances and transaction CSVs from Banca Transilvania George (go.bancatransilvania.ro).
|
||||
|
||||
**Security Context**: Authorized personal banking automation tool for educational purposes.
|
||||
|
||||
**⚠️ CRITICAL**: Docker/headless mode is **BLOCKED by WAF**. ONLY works locally with `HEADLESS=false`.
|
||||
**CRITICAL**: Docker/headless mode is **BLOCKED by WAF**. ONLY works locally with `HEADLESS=false`.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
**NO EMOJIS**: Do not use emojis in user-facing messages (Telegram, email, notifications). Use plain text only.
|
||||
|
||||
## Running the Scraper
|
||||
|
||||
@@ -50,30 +54,54 @@ login() → handle_2fa_wait() → read_accounts() → download_transactions()
|
||||
- Each card: `h4` (name), `span.text-grayscale-label` (IBAN), `strong.sold` (balance)
|
||||
- Balance format: `"7,223.26 RON"` → parse to float + currency
|
||||
|
||||
#### 4. Transaction Download Modal (lines ~312-420)
|
||||
#### 4. Transaction Download (lines ~529-732)
|
||||
|
||||
**State Machine:**
|
||||
**Flow (2024+ version):**
|
||||
```
|
||||
Account 1: Expand card → Click "Tranzacții" → Download → Back → Modal
|
||||
Account 2+: Select from modal → [ALREADY on page] → Download → Back
|
||||
Account 1: Expand card -> Click tranzactii icon -> Select "CSV" -> "Genereaza" -> Download from fba-document-item
|
||||
Account 2+: Click #selectAccountBtn -> Select by heading name -> "Genereaza" -> Download
|
||||
```
|
||||
|
||||
**Critical**: After modal selection, you're ALREADY on transactions page. Don't expand/click again.
|
||||
**Key methods:**
|
||||
- `_download_first_account()`: Handles first account (expand + select CSV format)
|
||||
- `_download_subsequent_account()`: Handles accounts 2+ (dropdown selection)
|
||||
- `_wait_and_download()`: Waits for fba-document-item and downloads
|
||||
|
||||
**Modal selectors:**
|
||||
- Modal: `.modal-content`
|
||||
- Account buttons: `#accountC14RONCRT{last_10_iban_digits}`
|
||||
- Example: IBAN `...0637236701` → `#accountC14RONCRT0637236701`
|
||||
**Account selection strategies (in order):**
|
||||
1. `get_by_role("heading", name=account_name)`
|
||||
2. `locator("fba-account-details").filter(has_text=account_name)`
|
||||
3. `get_by_text(account_name, exact=True)`
|
||||
|
||||
### Key Selectors
|
||||
|
||||
- Login: `get_by_placeholder("ID de logare")`, `get_by_placeholder("Parola")`
|
||||
- Post-login: `#accountsBtn`, `goapp.bancatransilvania.ro` domain
|
||||
- Accounts: `fba-account-details-card`, `.collapse-account-btn`, `.account-transactions-btn`
|
||||
- Modal: `.modal-content`, `#accountC14RONCRT{iban_digits}`
|
||||
- CSV: `get_by_role("button", name="CSV")`
|
||||
**Cookie consent:**
|
||||
- New (2024+): `get_by_role("button", name="Accept toate")`
|
||||
- One-time consent: `get_by_text("Am inteles")`
|
||||
|
||||
**Update selectors:** `playwright codegen https://btgo.ro --target python`
|
||||
**Login page:**
|
||||
- URL: `https://go.bancatransilvania.ro/`
|
||||
- Login link: `get_by_role("link", name="Login")`
|
||||
- Username: `get_by_placeholder("ID logare")` (intelligent fallback in `_find_username_field`)
|
||||
- Password: `get_by_placeholder("Parola")` or `input[type='password']`
|
||||
- Submit: `get_by_role("button", name="Autentifica-te")` (intelligent fallback in `_find_submit_button`)
|
||||
|
||||
**Post-login:**
|
||||
- 2FA success indicator: `#accountsBtn` visible and enabled
|
||||
- Domain: `goapp.bancatransilvania.ro`
|
||||
|
||||
**Accounts:**
|
||||
- Cards: `fba-account-details-card`
|
||||
- Expand icon: `.mx-auto .mat-icon svg`, `.collapse-account-btn`
|
||||
- Transactions button: `fba-account-buttons svg`, `.account-transactions-btn`
|
||||
|
||||
**Transaction download:**
|
||||
- Account selector: `#selectAccountBtn svg`
|
||||
- Account in dropdown: `get_by_role("heading", name=account_name)`
|
||||
- CSV format: `get_by_text("CSV", exact=True)`
|
||||
- Generate button: `get_by_role("button", name="Genereaza")`
|
||||
- Download item: `fba-document-item svg`, `fba-document-item path`
|
||||
|
||||
**Update selectors:** `playwright codegen https://go.bancatransilvania.ro --target python`
|
||||
|
||||
## Docker Limitation
|
||||
|
||||
@@ -93,17 +121,19 @@ Account 2+: Select from modal → [ALREADY on page] → Download → Back
|
||||
- **Fix**: Run locally with `HEADLESS=false`
|
||||
|
||||
### Transaction Download Timeout
|
||||
- Modal not detected: Check `.modal-content` selector
|
||||
- Account ID mismatch: Verify IBAN mapping `#accountC14RONCRT{last_10_digits}`
|
||||
- For idx > 1: Already on page, don't expand/click
|
||||
- Check `fba-document-item` selector (wait for document generation)
|
||||
- Verify `#selectAccountBtn` for account dropdown
|
||||
- Account selection: verify heading name matches exactly
|
||||
|
||||
### 2FA Timeout
|
||||
- Increase `TIMEOUT_2FA_SECONDS` in `.env`
|
||||
- Verify URL redirect to `goapp.bancatransilvania.ro`
|
||||
- Check for "Am inteles" consent dialog blocking
|
||||
|
||||
### "Nu exista card la pozitia X"
|
||||
- Trying to access cards in modal context
|
||||
- First account needs expand, subsequent accounts don't
|
||||
### Account Selection Failed
|
||||
- Account name might have changed - verify exact match
|
||||
- Try running `playwright codegen` to see current UI structure
|
||||
- Check if dropdown opened (`#selectAccountBtn`)
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
120
README.md
120
README.md
@@ -325,12 +325,8 @@ deployment\windows\scripts\menu.ps1
|
||||
- Mărește timeout: `TIMEOUT_2FA_SECONDS=180` în `.env`
|
||||
- Verifică notificări activate pe telefon
|
||||
|
||||
### Selectors nu funcționează
|
||||
Site-ul s-a schimbat. Re-generează selectors:
|
||||
```bash
|
||||
.venv\Scripts\activate
|
||||
playwright codegen https://btgo.ro --target python
|
||||
```
|
||||
### Selectors nu functioneaza
|
||||
Site-ul s-a schimbat. Urmeaza pasii din sectiunea **Inregistrare Manuala cu Playwright** de mai jos.
|
||||
|
||||
### Notificări Email nu funcționează
|
||||
- Pentru Gmail: folosește App Password, nu parola normală
|
||||
@@ -343,12 +339,116 @@ playwright codegen https://btgo.ro --target python
|
||||
- Chat ID pentru grupuri trebuie să fie **negativ** (ex: `-1001234567890`)
|
||||
- Asigură-te că botul este în grup
|
||||
|
||||
## Inregistrare Manuala cu Playwright (Codegen)
|
||||
|
||||
Cand site-ul BT George isi schimba interfata, trebuie sa reinregistrezi fluxul manual.
|
||||
|
||||
### 1. Porneste Playwright Codegen
|
||||
|
||||
```powershell
|
||||
# Activeaza venv-ul
|
||||
.venv\Scripts\Activate.ps1
|
||||
|
||||
# Porneste codegen
|
||||
playwright codegen https://go.bancatransilvania.ro --target python
|
||||
```
|
||||
|
||||
Se deschid 2 ferestre:
|
||||
- **Browser** - aici faci actiunile manual
|
||||
- **Playwright Inspector** - aici vezi codul Python generat
|
||||
|
||||
### 2. Inregistreaza Fluxul
|
||||
|
||||
1. Accept cookies ("Accept toate")
|
||||
2. Click pe "Login" - se deschide popup
|
||||
3. Completeaza username si parola
|
||||
4. Click "Autentifica-te"
|
||||
5. Asteapta 2FA pe telefon
|
||||
6. Dupa login, click pe "Conturi"
|
||||
7. **Pentru primul cont:**
|
||||
- Expand card (click pe sageata)
|
||||
- Click pe butonul "Tranzactii" (iconita cu grafic)
|
||||
- Click pe "CSV" pentru format
|
||||
- Click pe "Genereaza"
|
||||
- Click pe documentul generat pentru download
|
||||
8. **Pentru conturile urmatoare:**
|
||||
- Click pe `#selectAccountBtn` (dropdown conturi)
|
||||
- Selecteaza contul din lista
|
||||
- Click pe "Genereaza"
|
||||
- Download fisierul
|
||||
|
||||
### 3. Salveaza si Analizeaza Scriptul
|
||||
|
||||
- In Inspector: **Copy** sau **File > Save**
|
||||
- Compara cu `btgo_scraper.py` si actualizeaza selectorii modificati
|
||||
- Selectori cheie de verificat:
|
||||
- Cookie consent: `get_by_role("button", name="...")`
|
||||
- Username field: `get_by_placeholder("...")`
|
||||
- Submit button: `get_by_role("button", name="...")`
|
||||
- Account selector: `#selectAccountBtn`
|
||||
- Download item: `fba-document-item`
|
||||
|
||||
## Testare Manuala a Scraperului
|
||||
|
||||
### Testare Pas cu Pas
|
||||
|
||||
```powershell
|
||||
# 1. Activeaza venv
|
||||
.venv\Scripts\Activate.ps1
|
||||
|
||||
# 2. Ruleaza scraper-ul
|
||||
python btgo_scraper.py
|
||||
```
|
||||
|
||||
### Verificare Output
|
||||
|
||||
Dupa rulare, verifica:
|
||||
|
||||
```powershell
|
||||
# Fisiere generate
|
||||
dir data\
|
||||
|
||||
# Trebuie sa vezi:
|
||||
# - solduri_*.csv (solduri toate conturile)
|
||||
# - solduri_*.json (metadata + solduri)
|
||||
# - tranzactii_*.csv (cate un fisier per cont)
|
||||
# - dashboard_*.png (screenshot final)
|
||||
|
||||
# Verifica log-ul pentru erori
|
||||
type logs\scraper_*.log | Select-String -Pattern "EROARE|ERROR|Exception"
|
||||
```
|
||||
|
||||
### Testare Doar Solduri (fara download tranzactii)
|
||||
|
||||
```powershell
|
||||
# Seteaza variabila temporar
|
||||
$env:BALANCES_ONLY = "true"
|
||||
python btgo_scraper.py
|
||||
```
|
||||
|
||||
### Debug - Screenshot-uri
|
||||
|
||||
Daca ceva nu merge, verifica screenshot-urile din `data/`:
|
||||
- `debug_login_popup_*.png` - starea paginii de login
|
||||
- `debug_dropdown_*.png` - dropdown-ul de selectare conturi (daca esueaza)
|
||||
- `error_*.png` - screenshot la eroare
|
||||
|
||||
### Testare Notificari (fara scraping)
|
||||
|
||||
```powershell
|
||||
# Test Telegram
|
||||
python test_telegram.py
|
||||
|
||||
# Trimite ultimele fisiere manual
|
||||
python send_notifications.py
|
||||
```
|
||||
|
||||
## Securitate
|
||||
|
||||
**⚠️ IMPORTANT:**
|
||||
- NU comite `.env` în git (deja în `.gitignore`)
|
||||
- NU partaja screenshots/logs - conțin date sensibile
|
||||
- Șterge fișierele vechi periodic:
|
||||
**IMPORTANT:**
|
||||
- NU comite `.env` in git (deja in `.gitignore`)
|
||||
- NU partaja screenshots/logs - contin date sensibile
|
||||
- Sterge fisierele vechi periodic:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
|
||||
@@ -27,9 +27,13 @@ Tu (Telegram) → Bot → Rulează scraper → Trimite CSV-uri înapoi
|
||||
3. Alege nume pentru bot (ex: "BTGO Scraper Bot")
|
||||
4. Copiază **token-ul** primit (ex: `123456789:ABCdefGHIjklMNOpqrs`)
|
||||
|
||||
### 2. Obține User ID-ul Tău
|
||||
### 2. Obține User ID-ul Tău (OPȚIONAL)
|
||||
|
||||
Ai nevoie de User ID pentru securitate (doar tu poți rula scraper-ul).
|
||||
User ID e necesar doar dacă vrei să autorizezi utilizatori care **NU sunt în grup**.
|
||||
|
||||
**Dacă folosești grup:** Toți membrii grupului pot folosi bot-ul automat (în grup sau DM)!
|
||||
|
||||
**Pentru whitelist suplimentar:**
|
||||
|
||||
**Opțiunea A - Folosește bot existent:**
|
||||
```bash
|
||||
@@ -98,19 +102,20 @@ Editează `.env` și adaugă:
|
||||
# Bot token (același ca pentru notificări sau nou)
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrs
|
||||
|
||||
# User IDs autorizați (separați prin virgulă)
|
||||
# DOAR acești useri pot rula /scrape din grup
|
||||
TELEGRAM_ALLOWED_USER_IDS=123456789,987654321
|
||||
|
||||
# Chat ID GRUP pentru notificări automate + răspunsuri comenzi
|
||||
# Chat ID GRUP pentru notificări automate + autorizare membri
|
||||
# IMPORTANT: Negativ pentru grupuri! (ex: -1001234567890)
|
||||
# TOȚI membrii acestui grup pot folosi bot-ul (în grup sau DM)
|
||||
TELEGRAM_CHAT_ID=-1001234567890
|
||||
|
||||
# User IDs autorizați individual (OPȚIONAL - separați prin virgulă)
|
||||
# Pentru useri care NU sunt în grup dar vrei să le dai acces
|
||||
TELEGRAM_ALLOWED_USER_IDS=123456789,987654321
|
||||
```
|
||||
|
||||
**Securitate:**
|
||||
- `TELEGRAM_ALLOWED_USER_IDS` = doar acești useri pot rula `/scrape` din grup
|
||||
- Lasă gol dacă vrei ca oricine din grup să poată rula (nesigur!)
|
||||
- Bot-ul verifică User ID-ul celui care trimite comanda, NU group ID-ul
|
||||
**Autorizare:**
|
||||
- **Orice membru al grupului `TELEGRAM_CHAT_ID`** poate folosi bot-ul (în grup SAU în DM)
|
||||
- **SAU** useri din `TELEGRAM_ALLOWED_USER_IDS` (chiar dacă nu sunt în grup)
|
||||
- Dacă ambele sunt goale = bot deschis pentru oricine (NESIGUR!)
|
||||
|
||||
### 4. Pornire Bot
|
||||
|
||||
@@ -153,7 +158,7 @@ Bot pornit. Așteaptă comenzi...
|
||||
/help - Ajutor utilizare
|
||||
```
|
||||
|
||||
**Securitate:** Doar userii din `TELEGRAM_ALLOWED_USER_IDS` pot rula comenzi!
|
||||
**Autorizare:** Membri ai grupului TELEGRAM_CHAT_ID SAU useri din TELEGRAM_ALLOWED_USER_IDS pot rula comenzi!
|
||||
|
||||
### Flow Tipic în Grup
|
||||
|
||||
@@ -308,16 +313,18 @@ TELEGRAM_ALLOWED_USER_IDS=123456789
|
||||
|
||||
**⚠️ ATENȚIE:**
|
||||
- Bot-ul are acces la credentials din `.env`
|
||||
- `TELEGRAM_ALLOWED_USER_IDS` TREBUIE configurat!
|
||||
- `TELEGRAM_CHAT_ID` sau `TELEGRAM_ALLOWED_USER_IDS` TREBUIE configurat pentru securitate!
|
||||
- Nu partaja token-ul botului
|
||||
- VM-ul trebuie securizat (firewall, VPN)
|
||||
|
||||
**Best Practices:**
|
||||
```bash
|
||||
# ✅ Bun - doar tu și admin
|
||||
# ✅ Bun - grup autorizat + whitelist individual
|
||||
TELEGRAM_CHAT_ID=-1001234567890
|
||||
TELEGRAM_ALLOWED_USER_IDS=123456789,987654321
|
||||
|
||||
# ❌ Rău - oricine cu acces la bot
|
||||
# ❌ Rău - ambele goale (oricine are acces)
|
||||
TELEGRAM_CHAT_ID=
|
||||
TELEGRAM_ALLOWED_USER_IDS=
|
||||
|
||||
# ✅ Bun - notificări separate de trigger
|
||||
|
||||
768
btgo_scraper.py
768
btgo_scraper.py
@@ -90,10 +90,297 @@ class BTGoScraper:
|
||||
)
|
||||
logging.info(f"Progress update: {message}")
|
||||
|
||||
def _dismiss_gdpr_cookies(self, page, wait_for_banner=True):
|
||||
"""
|
||||
Inchide GDPR cookie banner daca este vizibil
|
||||
|
||||
Args:
|
||||
page: Pagina Playwright pe care sa verifice
|
||||
wait_for_banner: Daca True, asteapta aparitia banner-ului inainte de a incerca
|
||||
"""
|
||||
try:
|
||||
# Asteapta ca banner-ul GDPR sa apara (poate intarzia dupa page load)
|
||||
if wait_for_banner:
|
||||
try:
|
||||
page.wait_for_selector(".gdprcookie-wrapper", timeout=5000, state="visible")
|
||||
logging.info(" [INFO] Banner GDPR detectat, incerc sa-l inchid...")
|
||||
except:
|
||||
# Banner-ul nu a aparut in 5 secunde, continuam
|
||||
pass
|
||||
|
||||
# Strategii pentru cookie consent (in ordinea probabilitatii)
|
||||
cookie_strategies = [
|
||||
# 1. Noul buton BT (2024+)
|
||||
("role", "button", "Accept toate"),
|
||||
("role", "button", "Accepta toate"),
|
||||
("role", "button", "Acceptă toate"),
|
||||
|
||||
# 2. Vechiul GDPR wrapper - text specific
|
||||
("css", ".gdprcookie-wrapper button:has-text('Accept')"),
|
||||
("css", ".gdprcookie-wrapper button:has-text('Sunt de acord')"),
|
||||
("css", ".gdprcookie-wrapper button:has-text('Accepta')"),
|
||||
("css", ".gdprcookie-wrapper button:has-text('Acceptă')"),
|
||||
("css", ".gdprcookie-wrapper button:has-text('OK')"),
|
||||
|
||||
# 3. GDPR wrapper - orice buton (fallback agresiv)
|
||||
("css", ".gdprcookie-wrapper button"),
|
||||
("css", ".gdprcookie-wrapper .btn"),
|
||||
("css", ".gdprcookie-wrapper [role='button']"),
|
||||
|
||||
# 4. Fallback generic
|
||||
("role", "button", "Accept"),
|
||||
("role", "button", "Accepta"),
|
||||
("role", "button", "OK"),
|
||||
]
|
||||
|
||||
for strategy in cookie_strategies:
|
||||
try:
|
||||
if strategy[0] == "role":
|
||||
btn = page.get_by_role(strategy[1], name=strategy[2])
|
||||
else:
|
||||
btn = page.locator(strategy[2]).first
|
||||
|
||||
if btn.is_visible(timeout=3000):
|
||||
btn.click(force=True)
|
||||
logging.info(f" [OK] Cookies acceptate ({strategy})")
|
||||
time.sleep(1)
|
||||
|
||||
# Verifica ca banner-ul a disparut
|
||||
try:
|
||||
page.wait_for_selector(".gdprcookie-wrapper", timeout=2000, state="hidden")
|
||||
logging.info(" [OK] Banner GDPR inchis cu succes")
|
||||
except:
|
||||
# Incearca inca o data cu force click
|
||||
logging.info(" [WARN] Banner inca vizibil, reincercare...")
|
||||
try:
|
||||
btn.click(force=True)
|
||||
time.sleep(1)
|
||||
except:
|
||||
pass
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
|
||||
logging.info(" Nu exista cookie banner (sau deja acceptat)")
|
||||
return False
|
||||
except:
|
||||
logging.info(" Nu exista cookie banner (sau deja acceptat)")
|
||||
return False
|
||||
|
||||
def _dismiss_one_time_consent(self, page):
|
||||
"""
|
||||
Inchide dialoguri one-time (ex: 'Am inteles') daca apar
|
||||
|
||||
Args:
|
||||
page: Pagina Playwright pe care sa verifice
|
||||
"""
|
||||
consent_buttons = [
|
||||
("text", "Am înțeles"),
|
||||
("text", "Am inteles"),
|
||||
("role", "button", "Am înțeles"),
|
||||
("role", "button", "OK"),
|
||||
("role", "button", "Continua"),
|
||||
]
|
||||
|
||||
for strategy in consent_buttons:
|
||||
try:
|
||||
if strategy[0] == "text":
|
||||
btn = page.get_by_text(strategy[1], exact=True)
|
||||
elif strategy[0] == "role":
|
||||
btn = page.get_by_role(strategy[1], name=strategy[2])
|
||||
|
||||
if btn.is_visible(timeout=2000):
|
||||
btn.click()
|
||||
logging.info(f" [OK] Consent inchis ({strategy})")
|
||||
time.sleep(1)
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
def _find_username_field(self, page):
|
||||
"""
|
||||
Detecteaza inteligent campul de username folosind multiple strategii.
|
||||
Ordinea: selectori specifici -> selectori generici -> detectie structurala
|
||||
|
||||
Returns:
|
||||
Locator daca gasit, None altfel
|
||||
"""
|
||||
strategies = [
|
||||
# 1. Selectori specifici BT (pot sa se schimbe)
|
||||
("placeholder_exact", "ID logare"),
|
||||
("placeholder_exact", "ID de logare"),
|
||||
("id", "user"),
|
||||
("name", "user"),
|
||||
|
||||
# 2. Selectori generici (mai stabili)
|
||||
("placeholder_contains", "logare"),
|
||||
("placeholder_contains", "user"),
|
||||
("placeholder_contains", "utilizator"),
|
||||
("name_contains", "user"),
|
||||
("name_contains", "login"),
|
||||
("id_contains", "user"),
|
||||
("id_contains", "login"),
|
||||
|
||||
# 3. Selectori structurali (foarte stabili)
|
||||
("css", "form input[type='text']:not([type='hidden'])"),
|
||||
("css", "form input:not([type='password']):not([type='hidden']):not([type='submit'])"),
|
||||
("css", "input[type='text']"),
|
||||
("css", ".form-control[type='text']"),
|
||||
|
||||
# 4. Fallback - primul input vizibil care nu e password/submit
|
||||
("first_text_input", None),
|
||||
]
|
||||
|
||||
for strategy_type, value in strategies:
|
||||
try:
|
||||
field = self._try_field_strategy(page, strategy_type, value)
|
||||
if field and field.is_visible(timeout=1000):
|
||||
logging.info(f" [USERNAME] Gasit cu strategia: {strategy_type}='{value}'")
|
||||
return field
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
logging.error(" [USERNAME] Nu am gasit campul cu nicio strategie!")
|
||||
return None
|
||||
|
||||
def _find_password_field(self, page):
|
||||
"""
|
||||
Detecteaza inteligent campul de parola.
|
||||
Campul password e foarte stabil - type='password' e standard HTML.
|
||||
|
||||
Returns:
|
||||
Locator daca gasit, None altfel
|
||||
"""
|
||||
strategies = [
|
||||
# 1. Cel mai stabil - type='password' (standard HTML)
|
||||
("css", "input[type='password']"),
|
||||
|
||||
# 2. Selectori specifici BT
|
||||
("id", "password"),
|
||||
("name", "password"),
|
||||
("name", "pass"),
|
||||
("placeholder_exact", "Parola"),
|
||||
("placeholder_exact", "Password"),
|
||||
|
||||
# 3. Selectori generici
|
||||
("placeholder_contains", "parola"),
|
||||
("placeholder_contains", "password"),
|
||||
("name_contains", "pass"),
|
||||
("id_contains", "pass"),
|
||||
|
||||
# 4. Fallback structural
|
||||
("css", "form input[type='password']"),
|
||||
("css", ".form-control[type='password']"),
|
||||
]
|
||||
|
||||
for strategy_type, value in strategies:
|
||||
try:
|
||||
field = self._try_field_strategy(page, strategy_type, value)
|
||||
if field and field.is_visible(timeout=1000):
|
||||
logging.info(f" [PASSWORD] Gasit cu strategia: {strategy_type}='{value}'")
|
||||
return field
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
logging.error(" [PASSWORD] Nu am gasit campul cu nicio strategie!")
|
||||
return None
|
||||
|
||||
def _find_submit_button(self, page):
|
||||
"""
|
||||
Detecteaza inteligent butonul de submit.
|
||||
|
||||
Returns:
|
||||
Locator daca gasit, None altfel
|
||||
"""
|
||||
strategies = [
|
||||
# 1. Selectori specifici BT
|
||||
("css", "input[value='Autentifică-te']"),
|
||||
("css", "button:has-text('Autentifică-te')"),
|
||||
("css", "input[value*='Autentific']"),
|
||||
|
||||
# 2. Selectori generici pentru login buttons
|
||||
("css", "input[type='submit']"),
|
||||
("css", "button[type='submit']"),
|
||||
("css", "form button.btn-primary"),
|
||||
("css", "form input.btn-primary"),
|
||||
|
||||
# 3. Text-based (mai putin stabil dar functional)
|
||||
("text_contains", "Login"),
|
||||
("text_contains", "Conectare"),
|
||||
("text_contains", "Autentificare"),
|
||||
("text_contains", "Intra"),
|
||||
("text_contains", "Submit"),
|
||||
|
||||
# 4. Fallback - orice buton din form
|
||||
("css", "form button"),
|
||||
("css", "form input[type='button']"),
|
||||
("css", ".btn-primary"),
|
||||
]
|
||||
|
||||
for strategy_type, value in strategies:
|
||||
try:
|
||||
button = self._try_button_strategy(page, strategy_type, value)
|
||||
if button and button.is_visible(timeout=1000):
|
||||
logging.info(f" [SUBMIT] Gasit cu strategia: {strategy_type}='{value}'")
|
||||
return button
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
logging.error(" [SUBMIT] Nu am gasit butonul cu nicio strategie!")
|
||||
return None
|
||||
|
||||
def _try_field_strategy(self, page, strategy_type, value):
|
||||
"""Helper pentru a incerca o strategie de gasire a unui camp"""
|
||||
if strategy_type == "placeholder_exact":
|
||||
return page.get_by_placeholder(value, exact=True)
|
||||
elif strategy_type == "placeholder_contains":
|
||||
return page.locator(f"input[placeholder*='{value}' i]").first
|
||||
elif strategy_type == "id":
|
||||
return page.locator(f"#{value}")
|
||||
elif strategy_type == "id_contains":
|
||||
return page.locator(f"input[id*='{value}' i]").first
|
||||
elif strategy_type == "name":
|
||||
return page.locator(f"input[name='{value}']")
|
||||
elif strategy_type == "name_contains":
|
||||
return page.locator(f"input[name*='{value}' i]").first
|
||||
elif strategy_type == "css":
|
||||
return page.locator(value).first
|
||||
elif strategy_type == "label":
|
||||
return page.get_by_label(value)
|
||||
elif strategy_type == "first_text_input":
|
||||
# Gaseste primul input care nu e password, hidden sau submit
|
||||
inputs = page.locator("input:visible").all()
|
||||
for inp in inputs:
|
||||
try:
|
||||
inp_type = inp.get_attribute("type", timeout=500) or "text"
|
||||
if inp_type.lower() not in ["password", "hidden", "submit", "button", "checkbox", "radio"]:
|
||||
return inp
|
||||
except:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _try_button_strategy(self, page, strategy_type, value):
|
||||
"""Helper pentru a incerca o strategie de gasire a butonului"""
|
||||
if strategy_type == "css":
|
||||
return page.locator(value).first
|
||||
elif strategy_type == "text_contains":
|
||||
return page.locator(f"button:has-text('{value}'), input[value*='{value}' i]").first
|
||||
elif strategy_type == "role":
|
||||
return page.get_by_role("button", name=value)
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
"""Entry point principal - orchestreaza tot flow-ul"""
|
||||
try:
|
||||
# Check dacă rulăm în mod balances_only
|
||||
balances_only = os.getenv('BALANCES_ONLY', 'false').lower() == 'true'
|
||||
|
||||
logging.info("=" * 60)
|
||||
if balances_only:
|
||||
logging.info("Start BTGO Scraper (DOAR SOLDURI)")
|
||||
else:
|
||||
logging.info("Start BTGO Scraper")
|
||||
logging.info("=" * 60)
|
||||
|
||||
@@ -115,9 +402,11 @@ class BTGoScraper:
|
||||
accounts = self.read_accounts()
|
||||
csv_path, json_path = self.save_results(accounts)
|
||||
|
||||
# Descarcă tranzacții pentru toate conturile (optional)
|
||||
# Descarcă tranzacții pentru toate conturile (doar dacă nu e balances_only)
|
||||
downloaded_files = []
|
||||
if self.config.DOWNLOAD_TRANSACTIONS:
|
||||
if balances_only:
|
||||
logging.info("Mod DOAR SOLDURI - skip download tranzactii")
|
||||
elif self.config.DOWNLOAD_TRANSACTIONS:
|
||||
downloaded_files = self.download_transactions(accounts)
|
||||
else:
|
||||
logging.info("Download tranzacții dezactivat (DOWNLOAD_TRANSACTIONS=false)")
|
||||
@@ -161,14 +450,20 @@ class BTGoScraper:
|
||||
logging.info("Pagina incarcata")
|
||||
|
||||
try:
|
||||
# Cookie consent - asteapta si accepta
|
||||
logging.info("Acceptare cookies...")
|
||||
# Cookie consent - asteapta si accepta (GDPR wrapper)
|
||||
logging.info("Verificare GDPR cookie banner...")
|
||||
self._dismiss_gdpr_cookies(self.page)
|
||||
|
||||
# Verificare finala ca banner-ul nu mai blocheaza
|
||||
try:
|
||||
cookie_button = self.page.get_by_role("button", name="Sunt de acord", exact=True)
|
||||
cookie_button.click(timeout=5000)
|
||||
logging.info("✓ Cookies acceptate")
|
||||
gdpr_wrapper = self.page.locator(".gdprcookie-wrapper")
|
||||
if gdpr_wrapper.is_visible(timeout=1000):
|
||||
logging.warning(" [WARN] Banner GDPR inca vizibil, fortez inchiderea...")
|
||||
# Incearca sa inchida prin JavaScript
|
||||
self.page.evaluate("document.querySelector('.gdprcookie-wrapper')?.remove()")
|
||||
time.sleep(0.5)
|
||||
except:
|
||||
logging.info("Nu a fost necesar acceptul cookies (posibil deja acceptat)")
|
||||
pass
|
||||
|
||||
# Click pe butonul LOGIN - deschide popup
|
||||
logging.info("Click pe butonul LOGIN...")
|
||||
@@ -180,23 +475,40 @@ class BTGoScraper:
|
||||
self.login_page = popup_info.value
|
||||
logging.info("✓ Popup login deschis")
|
||||
|
||||
# Completare username
|
||||
logging.info("Completare username...")
|
||||
username_field = self.login_page.get_by_placeholder("ID de logare")
|
||||
# Verifica GDPR cookies si pe popup
|
||||
self._dismiss_gdpr_cookies(self.login_page)
|
||||
|
||||
# Asteapta sa se incarce pagina de login
|
||||
time.sleep(2)
|
||||
|
||||
# Screenshot debug pentru a vedea starea paginii
|
||||
debug_path = Path(self.config.OUTPUT_DIR) / f"debug_login_popup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
self.login_page.screenshot(path=str(debug_path))
|
||||
logging.info(f"Screenshot debug salvat: {debug_path}")
|
||||
|
||||
# Completare username - detectie inteligenta cu fallback
|
||||
logging.info("Detectie camp username...")
|
||||
username_field = self._find_username_field(self.login_page)
|
||||
if not username_field:
|
||||
raise Exception("Nu am gasit campul de username cu nicio strategie!")
|
||||
username_field.fill(self.config.BTGO_USERNAME)
|
||||
logging.info("✓ Username completat")
|
||||
logging.info("[OK] Username completat")
|
||||
|
||||
# Completare password
|
||||
logging.info("Completare password...")
|
||||
password_field = self.login_page.get_by_placeholder("Parola")
|
||||
# Completare password - detectie inteligenta cu fallback
|
||||
logging.info("Detectie camp password...")
|
||||
password_field = self._find_password_field(self.login_page)
|
||||
if not password_field:
|
||||
raise Exception("Nu am gasit campul de parola cu nicio strategie!")
|
||||
password_field.fill(self.config.BTGO_PASSWORD)
|
||||
logging.info("✓ Password completat")
|
||||
logging.info("[OK] Password completat")
|
||||
|
||||
# Click pe butonul de submit
|
||||
logging.info("Click pe 'Mergi mai departe'...")
|
||||
submit_button = self.login_page.get_by_role("button", name="Mergi mai departe")
|
||||
# Click pe butonul de submit - detectie inteligenta cu fallback
|
||||
logging.info("Detectie buton submit...")
|
||||
submit_button = self._find_submit_button(self.login_page)
|
||||
if not submit_button:
|
||||
raise Exception("Nu am gasit butonul de submit cu nicio strategie!")
|
||||
submit_button.click()
|
||||
logging.info("✓ Credentials trimise, astept 2FA...")
|
||||
logging.info("[OK] Credentials trimise, astept 2FA...")
|
||||
self._update_progress("Astept aprobare 2FA pe telefon...")
|
||||
|
||||
except PlaywrightTimeout as e:
|
||||
@@ -234,10 +546,14 @@ class BTGoScraper:
|
||||
if accounts_btn.is_visible(timeout=1000):
|
||||
# Verifică că este și clickable (enabled)
|
||||
if accounts_btn.is_enabled():
|
||||
logging.info("✓ Autentificare 2FA reusita! (Buton conturi activ)")
|
||||
logging.info("[OK] Autentificare 2FA reusita! (Buton conturi activ)")
|
||||
time.sleep(2) # Asteapta ca pagina sa se stabilizeze complet
|
||||
# Update page reference la login_page pentru restul operatiilor
|
||||
self.page = self.login_page
|
||||
|
||||
# Inchide dialoguri one-time (ex: "Am inteles") daca apar
|
||||
self._dismiss_one_time_consent(self.page)
|
||||
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
@@ -253,30 +569,19 @@ class BTGoScraper:
|
||||
raise TimeoutError(f"Timeout 2FA dupa {timeout} secunde. Verifica ca ai aprobat pe telefon!")
|
||||
|
||||
def download_transactions(self, accounts):
|
||||
"""Descarca CSV-uri cu tranzactiile pentru fiecare cont"""
|
||||
"""
|
||||
Descarca CSV-uri cu tranzactiile pentru fiecare cont.
|
||||
|
||||
Flux nou (2024+):
|
||||
1. Primul cont: expand card -> click tranzactii -> select CSV -> Genereaza -> download
|
||||
2. Conturile urmatoare: #selectAccountBtn -> select cont by heading -> Genereaza -> download
|
||||
"""
|
||||
logging.info("=" * 60)
|
||||
logging.info("Descarcare tranzactii pentru toate conturile...")
|
||||
logging.info("=" * 60)
|
||||
|
||||
downloaded_files = []
|
||||
|
||||
# IMPORTANT: Collapse toate conturile mai intai
|
||||
logging.info("Collapse toate conturile...")
|
||||
all_expanded = self.page.locator(".mat-icon.rotate-90").all()
|
||||
for expanded_icon in all_expanded:
|
||||
try:
|
||||
expanded_icon.click()
|
||||
time.sleep(0.3)
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(1)
|
||||
logging.info("✓ Toate conturile sunt collapse")
|
||||
|
||||
# Re-gaseste toate cardurile de conturi
|
||||
all_cards = self.page.locator("fba-account-details-card").all()
|
||||
logging.info(f"Gasit {len(all_cards)} carduri de conturi")
|
||||
|
||||
for idx, account in enumerate(accounts, 1):
|
||||
try:
|
||||
nume_cont = account['nume_cont']
|
||||
@@ -285,118 +590,19 @@ class BTGoScraper:
|
||||
self._update_progress(f"Descarc tranzactii ({idx}/{len(accounts)})...")
|
||||
logging.info(f"[{idx}/{len(accounts)}] Descarcare tranzactii pentru: {nume_cont}")
|
||||
|
||||
# Doar pentru PRIMUL cont trebuie expand + click Tranzacții
|
||||
# Pentru restul, suntem deja pe pagina de tranzacții (din selectarea din modal)
|
||||
if idx == 1:
|
||||
# Primul cont - expand și click Tranzacții
|
||||
if idx - 1 >= len(all_cards):
|
||||
logging.error(f" ✗ Nu exista card la pozitia {idx-1}")
|
||||
continue
|
||||
|
||||
card = all_cards[idx - 1]
|
||||
|
||||
# Expand contul (click pe săgeată)
|
||||
expand_button = card.locator(".collapse-account-btn").first
|
||||
expand_button.click()
|
||||
time.sleep(2) # Așteaptă expandare
|
||||
logging.info(f" Contul expandat")
|
||||
|
||||
# Click pe butonul Tranzacții
|
||||
try:
|
||||
transactions_button = card.locator(".account-transactions-btn").first
|
||||
transactions_button.click()
|
||||
time.sleep(3) # Așteaptă încărcarea paginii cu tranzacții
|
||||
logging.info(f" Click pe buton Tranzactii - pagina se incarca...")
|
||||
except Exception as e:
|
||||
logging.error(f" ✗ Nu am gasit butonul Tranzactii: {e}")
|
||||
try:
|
||||
expand_button.click()
|
||||
time.sleep(0.5)
|
||||
except:
|
||||
pass
|
||||
continue
|
||||
# PRIMUL CONT: expand -> click tranzactii -> select CSV
|
||||
downloaded = self._download_first_account(account)
|
||||
else:
|
||||
# Conturile 2-5: suntem deja pe pagina de tranzacții (din modal)
|
||||
logging.info(f" Deja pe pagina tranzactii (selectat din modal)")
|
||||
time.sleep(2) # Așteaptă stabilizare pagină
|
||||
# CONTURILE URMATOARE: selecteaza din dropdown -> Genereaza -> download
|
||||
downloaded = self._download_subsequent_account(account)
|
||||
|
||||
# Așteaptă să apară butonul CSV (indica că pagina s-a încărcat)
|
||||
try:
|
||||
self.page.wait_for_selector('button:has-text("CSV")', timeout=5000)
|
||||
logging.info(f" Buton CSV detectat")
|
||||
except:
|
||||
logging.warning(f" Timeout asteptand butonul CSV")
|
||||
|
||||
# Click pe butonul CSV și așteaptă download
|
||||
try:
|
||||
with self.page.expect_download(timeout=15000) as download_info:
|
||||
csv_button = self.page.get_by_role("button", name="CSV")
|
||||
csv_button.click()
|
||||
logging.info(f" Click pe butonul CSV - astept download...")
|
||||
|
||||
download = download_info.value
|
||||
|
||||
# Salvează fișierul cu un nume descriptiv
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
nume_safe = nume_cont.replace(' ', '_').replace('/', '_')
|
||||
filename = f"tranzactii_{nume_safe}_{timestamp}.csv"
|
||||
save_path = Path(self.config.OUTPUT_DIR) / filename
|
||||
|
||||
download.save_as(save_path)
|
||||
logging.info(f" ✓ Salvat: {save_path}")
|
||||
|
||||
downloaded_files.append({
|
||||
'cont': nume_cont,
|
||||
'iban': iban,
|
||||
'fisier': str(save_path)
|
||||
})
|
||||
if downloaded:
|
||||
downloaded_files.append(downloaded)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f" ✗ Eroare la descarcarea CSV: {e}")
|
||||
|
||||
# Navighează înapoi la lista de conturi
|
||||
try:
|
||||
# Click pe butonul back/close (săgeată stânga sau X)
|
||||
back_button = self.page.locator('button[aria-label="Back"], .back-button, #selectAccountBtn').first
|
||||
back_button.click()
|
||||
time.sleep(1.5)
|
||||
logging.info(f" Navigat inapoi - verific modal...")
|
||||
except Exception as e:
|
||||
logging.warning(f" Nu am putut naviga inapoi: {e}")
|
||||
time.sleep(1)
|
||||
|
||||
# Verifică dacă a apărut modal de selectare cont
|
||||
try:
|
||||
modal_visible = self.page.locator('.modal-content').is_visible(timeout=2000)
|
||||
if modal_visible and idx < len(accounts):
|
||||
logging.info(f" Modal detectat - selectez contul urmator...")
|
||||
|
||||
# Calculează ID-ul contului următor
|
||||
next_account = accounts[idx] # idx este 0-indexed pentru next
|
||||
next_iban = next_account['iban']
|
||||
next_iban_digits = ''.join(filter(str.isdigit, next_iban))[-10:]
|
||||
next_account_id = f"accountC14RONCRT{next_iban_digits}"
|
||||
|
||||
# Click pe contul următor din modal
|
||||
modal_account = self.page.locator(f'#{next_account_id}').first
|
||||
modal_account.click()
|
||||
time.sleep(2)
|
||||
logging.info(f" ✓ Selectat cont din modal: {next_account['nume_cont']}")
|
||||
else:
|
||||
# Nu e modal - e ultima iteratie sau nu a aparut modal
|
||||
logging.info(f" Nu e modal - continuam normal")
|
||||
except Exception as e:
|
||||
logging.warning(f" Eroare verificare modal: {e}")
|
||||
|
||||
# Re-găsește cardurile (pentru flow normal fără modal)
|
||||
try:
|
||||
all_cards = self.page.locator("fba-account-details-card").all()
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f" ✗ Eroare la descarcarea tranzactiilor pentru {nume_cont}: {e}")
|
||||
# Încearcă să navighezi înapoi
|
||||
logging.error(f" [EROARE] Eroare la descarcarea tranzactiilor pentru {nume_cont}: {e}")
|
||||
# Incearca sa revii la o stare stabila
|
||||
try:
|
||||
self.page.keyboard.press("Escape")
|
||||
time.sleep(1)
|
||||
@@ -405,11 +611,279 @@ class BTGoScraper:
|
||||
continue
|
||||
|
||||
logging.info("=" * 60)
|
||||
logging.info(f"✓ Descarcate {len(downloaded_files)}/{len(accounts)} fisiere CSV cu tranzactii")
|
||||
logging.info(f"[OK] Descarcate {len(downloaded_files)}/{len(accounts)} fisiere CSV cu tranzactii")
|
||||
logging.info("=" * 60)
|
||||
|
||||
return downloaded_files
|
||||
|
||||
def _download_first_account(self, account):
|
||||
"""
|
||||
Descarca tranzactii pentru primul cont.
|
||||
Flow: expand card (daca nu e deja) -> click buton tranzactii -> select CSV -> Genereaza -> download
|
||||
"""
|
||||
nume_cont = account['nume_cont']
|
||||
iban = account['iban']
|
||||
|
||||
try:
|
||||
# Gaseste primul card
|
||||
first_card = self.page.locator("fba-account-details-card").first
|
||||
time.sleep(1)
|
||||
|
||||
# Verifica daca butonul de tranzactii e DEJA vizibil (cont expandat)
|
||||
transactions_btn = first_card.locator("fba-account-buttons svg, fba-account-buttons, .account-transactions-btn").first
|
||||
is_already_expanded = False
|
||||
|
||||
try:
|
||||
is_already_expanded = transactions_btn.is_visible(timeout=2000)
|
||||
except:
|
||||
pass
|
||||
|
||||
if is_already_expanded:
|
||||
logging.info(" Contul deja expandat - skip expand click")
|
||||
else:
|
||||
# Click pe expand icon (sageata din card)
|
||||
logging.info(" Contul collapsed - expandez...")
|
||||
expand_icon = first_card.locator(".mx-auto .mat-icon svg, .collapse-account-btn").first
|
||||
expand_icon.click()
|
||||
time.sleep(2)
|
||||
logging.info(" Contul expandat")
|
||||
|
||||
# Click pe butonul de tranzactii (NU pe delete/inchide cont!)
|
||||
# Butonul corect are: clasa .account-transactions-btn, SVG cu documentChartList, <p> cu "Tranzactii"
|
||||
transactions_btn = None
|
||||
|
||||
# Strategie 1 (PRINCIPALA): container cu clasa account-transactions-btn
|
||||
try:
|
||||
btn = first_card.locator(".account-transactions-btn").first
|
||||
if btn.is_visible(timeout=2000):
|
||||
transactions_btn = btn
|
||||
logging.info(" Buton tranzactii gasit prin .account-transactions-btn")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Strategie 2: SVG cu data-mat-icon-name="documentChartList"
|
||||
if not transactions_btn:
|
||||
try:
|
||||
btn = first_card.locator("mat-icon[data-mat-icon-name='documentChartList']").first
|
||||
if btn.is_visible(timeout=1000):
|
||||
transactions_btn = btn
|
||||
logging.info(" Buton tranzactii gasit prin documentChartList icon")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Strategie 3: element care contine <p> cu text "Tranzactii"
|
||||
if not transactions_btn:
|
||||
try:
|
||||
btn = first_card.locator("div:has(p:text('Tranzacții')), div:has(p:text('Tranzactii'))").first
|
||||
if btn.is_visible(timeout=1000):
|
||||
transactions_btn = btn
|
||||
logging.info(" Buton tranzactii gasit prin <p> text")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Strategie 4: click direct pe textul "Tranzactii" din card
|
||||
if not transactions_btn:
|
||||
try:
|
||||
btn = first_card.get_by_text("Tranzacții", exact=True)
|
||||
if btn.is_visible(timeout=1000):
|
||||
transactions_btn = btn
|
||||
logging.info(" Buton tranzactii gasit prin get_by_text")
|
||||
except:
|
||||
pass
|
||||
|
||||
if not transactions_btn:
|
||||
raise Exception("Nu am gasit butonul de tranzactii!")
|
||||
|
||||
transactions_btn.click()
|
||||
time.sleep(3)
|
||||
logging.info(" Pagina tranzactii se incarca...")
|
||||
|
||||
# Selecteaza format CSV (click pe text "CSV")
|
||||
csv_option = self.page.get_by_text("CSV", exact=True)
|
||||
csv_option.click()
|
||||
time.sleep(1)
|
||||
logging.info(" Format CSV selectat")
|
||||
|
||||
# Click pe butonul Genereaza
|
||||
generate_btn = self.page.get_by_role("button", name="Generează")
|
||||
generate_btn.click()
|
||||
time.sleep(2)
|
||||
logging.info(" Click Genereaza - astept generare...")
|
||||
|
||||
# Asteapta si descarca fisierul
|
||||
return self._wait_and_download(nume_cont, iban)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f" [EROARE] Download primul cont: {e}")
|
||||
return None
|
||||
|
||||
def _download_subsequent_account(self, account):
|
||||
"""
|
||||
Descarca tranzactii pentru conturile 2+.
|
||||
Flow: click #selectAccountBtn -> select cont by heading -> Genereaza -> download
|
||||
"""
|
||||
nume_cont = account['nume_cont']
|
||||
iban = account['iban']
|
||||
|
||||
try:
|
||||
# Click pe butonul de selectare cont (#selectAccountBtn)
|
||||
select_btn = self.page.locator("#selectAccountBtn svg, #selectAccountBtn").first
|
||||
select_btn.click()
|
||||
time.sleep(2)
|
||||
logging.info(" Dropdown conturi deschis")
|
||||
|
||||
# Debug: listeaza toate heading-urile vizibile din dropdown
|
||||
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):
|
||||
visible_names.append(h.inner_text().strip())
|
||||
except:
|
||||
pass
|
||||
if visible_names:
|
||||
logging.info(f" Conturi in dropdown: {visible_names}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Selecteaza contul dupa nume - strategii multiple
|
||||
account_selected = False
|
||||
|
||||
# Strategie 1: heading cu numele exact
|
||||
try:
|
||||
heading = self.page.get_by_role("heading", name=nume_cont, exact=True)
|
||||
if heading.is_visible(timeout=2000):
|
||||
heading.click()
|
||||
account_selected = True
|
||||
logging.info(f" Cont selectat prin heading exact: {nume_cont}")
|
||||
except Exception as e:
|
||||
logging.debug(f" Heading exact failed: {e}")
|
||||
|
||||
# Strategie 2: heading cu numele partial (fara exact match)
|
||||
if not account_selected:
|
||||
try:
|
||||
heading = self.page.get_by_role("heading", name=nume_cont)
|
||||
if heading.is_visible(timeout=2000):
|
||||
heading.click()
|
||||
account_selected = True
|
||||
logging.info(f" Cont selectat prin heading partial: {nume_cont}")
|
||||
except Exception as e:
|
||||
logging.debug(f" Heading partial failed: {e}")
|
||||
|
||||
# Strategie 3: fba-account-details cu has_text
|
||||
if not account_selected:
|
||||
try:
|
||||
account_item = self.page.locator("fba-account-details").filter(has_text=nume_cont).first
|
||||
if account_item.is_visible(timeout=2000):
|
||||
account_item.click()
|
||||
account_selected = True
|
||||
logging.info(f" Cont selectat prin fba-account-details: {nume_cont}")
|
||||
except Exception as e:
|
||||
logging.debug(f" fba-account-details failed: {e}")
|
||||
|
||||
# Strategie 4: locator h4 care contine textul
|
||||
if not account_selected:
|
||||
try:
|
||||
h4_elem = self.page.locator(f"h4:has-text('{nume_cont}')").first
|
||||
if h4_elem.is_visible(timeout=2000):
|
||||
h4_elem.click()
|
||||
account_selected = True
|
||||
logging.info(f" Cont selectat prin h4:has-text: {nume_cont}")
|
||||
except Exception as e:
|
||||
logging.debug(f" h4:has-text failed: {e}")
|
||||
|
||||
# Strategie 5: orice element cu textul contului
|
||||
if not account_selected:
|
||||
try:
|
||||
text_elem = self.page.get_by_text(nume_cont, exact=True)
|
||||
if text_elem.is_visible(timeout=2000):
|
||||
text_elem.click()
|
||||
account_selected = True
|
||||
logging.info(f" Cont selectat prin text exact: {nume_cont}")
|
||||
except Exception as e:
|
||||
logging.debug(f" text exact failed: {e}")
|
||||
|
||||
# Strategie 6: text partial match
|
||||
if not account_selected:
|
||||
try:
|
||||
text_elem = self.page.get_by_text(nume_cont)
|
||||
if text_elem.is_visible(timeout=2000):
|
||||
text_elem.click()
|
||||
account_selected = True
|
||||
logging.info(f" Cont selectat prin text partial: {nume_cont}")
|
||||
except Exception as e:
|
||||
logging.debug(f" text partial failed: {e}")
|
||||
|
||||
if not account_selected:
|
||||
logging.error(f" [EROARE] Nu am putut selecta contul: {nume_cont}")
|
||||
# Screenshot pentru debug
|
||||
try:
|
||||
debug_path = Path(self.config.OUTPUT_DIR) / f"debug_dropdown_{nume_cont.replace(' ', '_')}_{datetime.now().strftime('%H%M%S')}.png"
|
||||
self.page.screenshot(path=str(debug_path))
|
||||
logging.error(f" Screenshot debug salvat: {debug_path}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Click pe butonul Genereaza (CSV deja selectat de la primul cont)
|
||||
generate_btn = self.page.get_by_role("button", name="Generează")
|
||||
generate_btn.click()
|
||||
time.sleep(2)
|
||||
logging.info(" Click Genereaza - astept generare...")
|
||||
|
||||
# Asteapta si descarca fisierul
|
||||
return self._wait_and_download(nume_cont, iban)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f" [EROARE] Download cont ulterior {nume_cont}: {e}")
|
||||
return None
|
||||
|
||||
def _wait_and_download(self, nume_cont, iban, timeout=20000):
|
||||
"""
|
||||
Asteapta generarea fisierului si il descarca.
|
||||
|
||||
Args:
|
||||
nume_cont: Numele contului (pentru filename)
|
||||
iban: IBAN-ul contului
|
||||
timeout: Timeout pentru download (ms)
|
||||
|
||||
Returns:
|
||||
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...")
|
||||
|
||||
# Click pe document item 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.click()
|
||||
|
||||
download = download_info.value
|
||||
|
||||
# Salveaza fisierul cu nume descriptiv
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
nume_safe = nume_cont.replace(' ', '_').replace('/', '_').replace('\\', '_')
|
||||
filename = f"tranzactii_{nume_safe}_{timestamp}.csv"
|
||||
save_path = Path(self.config.OUTPUT_DIR) / filename
|
||||
|
||||
download.save_as(save_path)
|
||||
logging.info(f" [OK] Salvat: {save_path}")
|
||||
|
||||
return {
|
||||
'cont': nume_cont,
|
||||
'iban': iban,
|
||||
'fisier': str(save_path)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f" [EROARE] Download fisier: {e}")
|
||||
return None
|
||||
|
||||
def read_accounts(self):
|
||||
"""Extrage soldurile tuturor conturilor"""
|
||||
logging.info("Citire conturi si solduri...")
|
||||
|
||||
@@ -58,6 +58,9 @@ function Show-MainMenu {
|
||||
Write-Host " [R] Run Scraper (Manual)" -ForegroundColor Cyan
|
||||
Write-Host " [T] Run Telegram Bot (Manual)" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Maintenance:" -ForegroundColor DarkCyan
|
||||
Write-Host " [G] Git Pull & Restart Service" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " [A] Open Deployment README" -ForegroundColor Gray
|
||||
Write-Host " [B] Open Quick Start Guide" -ForegroundColor Gray
|
||||
Write-Host " [C] Open Project in Explorer" -ForegroundColor Gray
|
||||
@@ -270,6 +273,73 @@ function Invoke-UpdateBrowsers {
|
||||
Read-Host "`nApasa Enter pentru a reveni la meniu"
|
||||
}
|
||||
|
||||
function Invoke-GitPullRestart {
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 80) -ForegroundColor Cyan
|
||||
Write-Host "GIT PULL & RESTART SERVICE" -ForegroundColor Yellow
|
||||
Write-Host ("=" * 80) -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Git pull
|
||||
Write-Host "[INFO] Actualizare proiect din Git..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Push-Location $ProjectDir
|
||||
try {
|
||||
git pull
|
||||
$gitExitCode = $LASTEXITCODE
|
||||
Write-Host ""
|
||||
|
||||
if ($gitExitCode -eq 0) {
|
||||
Write-Host "[SUCCES] Proiect actualizat!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Restart service if installed (inline, fara script extern)
|
||||
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($service) {
|
||||
Write-Host "[INFO] Oprire serviciu..." -ForegroundColor Cyan
|
||||
try {
|
||||
Stop-Service -Name $ServiceName -Force
|
||||
Write-Host "[OK] Serviciu oprit" -ForegroundColor Green
|
||||
Start-Sleep -Seconds 2
|
||||
} catch {
|
||||
Write-Host "[AVERTIZARE] Oprirea a esuat, incercam oprire fortata..." -ForegroundColor Yellow
|
||||
sc.exe stop $ServiceName | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
Write-Host "[INFO] Pornire serviciu..." -ForegroundColor Cyan
|
||||
try {
|
||||
Start-Service -Name $ServiceName
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$service = Get-Service -Name $ServiceName
|
||||
if ($service.Status -eq "Running") {
|
||||
Write-Host "[SUCCES] Serviciu restartat cu succes!" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[EROARE] Serviciul nu ruleaza dupa restart!" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[EROARE] Pornirea serviciului a esuat!" -ForegroundColor Red
|
||||
Write-Host "Verificati logurile pentru detalii" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "[INFO] Serviciul nu este instalat, restart nu este necesar" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host "[EROARE] Git pull a esuat cu codul $gitExitCode" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[EROARE] Eroare la git pull: $_" -ForegroundColor Red
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Read-Host "Apasa Enter pentru a reveni la meniu"
|
||||
}
|
||||
|
||||
# Main loop
|
||||
do {
|
||||
Show-MainMenu
|
||||
@@ -301,6 +371,8 @@ do {
|
||||
"t" { Invoke-RunTelegramBotManual }
|
||||
"U" { Invoke-UpdateBrowsers }
|
||||
"u" { Invoke-UpdateBrowsers }
|
||||
"G" { Invoke-GitPullRestart }
|
||||
"g" { Invoke-GitPullRestart }
|
||||
"0" {
|
||||
Write-Host ""
|
||||
Write-Host "Goodbye!" -ForegroundColor Green
|
||||
|
||||
@@ -4,6 +4,7 @@ Helper script pentru obținerea Chat ID Telegram (pentru DM și grupuri)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -13,11 +14,20 @@ load_dotenv()
|
||||
BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
|
||||
|
||||
if not BOT_TOKEN:
|
||||
print("❌ TELEGRAM_BOT_TOKEN nu este setat în .env!")
|
||||
print("=" * 60)
|
||||
print("EROARE: TELEGRAM_BOT_TOKEN nu este setat în .env")
|
||||
print("=" * 60)
|
||||
print("\nAdaugă în .env:")
|
||||
print("TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrs")
|
||||
print("=" * 60)
|
||||
exit(1)
|
||||
|
||||
def get_bot_info():
|
||||
"""Preia informații despre bot"""
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/getMe"
|
||||
response = requests.get(url)
|
||||
return response.json()
|
||||
|
||||
def get_updates():
|
||||
"""Preia ultimele update-uri de la bot"""
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/getUpdates"
|
||||
@@ -28,96 +38,102 @@ def main():
|
||||
print("=" * 60)
|
||||
print(" Telegram Chat ID Helper")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("📱 Instrucțiuni:")
|
||||
print("1. Trimite /start către bot (DM)")
|
||||
print(" SAU")
|
||||
print("2. Trimite /start în grupul unde ai bot-ul")
|
||||
print()
|
||||
print("Apasă Enter după ce ai trimis mesajul...")
|
||||
input()
|
||||
|
||||
print("\n🔍 Căutare mesaje...")
|
||||
# Verifică info bot
|
||||
bot_data = get_bot_info()
|
||||
if bot_data.get('ok'):
|
||||
bot = bot_data['result']
|
||||
print(f"\nBot: @{bot.get('username')} ({bot.get('first_name')})")
|
||||
print(f"Bot ID: {bot.get('id')}")
|
||||
print(f"Status: ACTIV")
|
||||
else:
|
||||
print(f"\nERORE: Token invalid - {bot_data}")
|
||||
exit(1)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Căutare mesaje...")
|
||||
print("=" * 60)
|
||||
|
||||
data = get_updates()
|
||||
|
||||
if not data.get('ok'):
|
||||
print(f"❌ Eroare API: {data}")
|
||||
print(f"\nERORE API: {data}")
|
||||
return
|
||||
|
||||
results = data.get('result', [])
|
||||
if not results:
|
||||
print("❌ Niciun mesaj găsit!")
|
||||
print("\nAsigură-te că:")
|
||||
print("• Ai trimis /start către bot SAU în grup")
|
||||
print("• Bot-ul este adăugat în grup (dacă folosești grup)")
|
||||
print("• Token-ul este corect")
|
||||
print("\nNU S-AU GĂSIT MESAJE!")
|
||||
print("\n" + "=" * 60)
|
||||
print("INSTRUCȚIUNI:")
|
||||
print("=" * 60)
|
||||
print("1. Deschide Telegram")
|
||||
print(f"2. Caută @{bot.get('username')} SAU deschide grupul cu bot-ul")
|
||||
print("3. Trimite un mesaj (ex: /start sau /info)")
|
||||
print("4. Rulează din nou acest script")
|
||||
print("=" * 60)
|
||||
return
|
||||
|
||||
print(f"\n✅ Găsit {len(results)} mesaje!\n")
|
||||
print(f"\nGăsit {len(results)} mesaje în total")
|
||||
|
||||
# Procesează ultimele mesaje (ultimele 20)
|
||||
seen_chats = {}
|
||||
user_ids = set()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("CHAT IDs DETECTATE:")
|
||||
print("=" * 60)
|
||||
|
||||
# Procesează ultimele mesaje
|
||||
seen_chats = {}
|
||||
|
||||
for update in results:
|
||||
for update in results[-20:]: # Ultimele 20 mesaje
|
||||
if 'message' in update:
|
||||
msg = update['message']
|
||||
chat = msg['chat']
|
||||
chat_id = chat['id']
|
||||
chat_type = chat['type']
|
||||
user = msg['from']
|
||||
user_ids.add(user['id'])
|
||||
text = msg.get('text', '(no text)')
|
||||
|
||||
# Evită duplicate
|
||||
if chat_id in seen_chats:
|
||||
continue
|
||||
|
||||
seen_chats[chat_id] = True
|
||||
seen_chats[chat_id] = chat
|
||||
|
||||
# Detalii chat
|
||||
if chat_type == 'private':
|
||||
# DM
|
||||
user = msg['from']
|
||||
print(f"📱 DM cu {user.get('first_name', 'Unknown')}")
|
||||
print(f" User ID: {user['id']}")
|
||||
print(f"\n[DM] {user.get('first_name', '')} {user.get('last_name', '')}")
|
||||
print(f" Username: @{user.get('username', 'N/A')}")
|
||||
print(f" User ID: {user['id']}")
|
||||
print(f" Chat ID: {chat_id}")
|
||||
elif chat_type in ['group', 'supergroup']:
|
||||
# Grup
|
||||
print(f"👥 Grup: {chat.get('title', 'Unknown')}")
|
||||
print(f" Chat ID: {chat_id} ⚠️ NEGATIV pentru grupuri!")
|
||||
print(f"\n[GRUP] {chat.get('title', 'Unknown')}")
|
||||
print(f" Chat ID: {chat_id}")
|
||||
print(f" Tip: {chat_type}")
|
||||
# User care a trimis mesajul
|
||||
user = msg['from']
|
||||
print(f" Mesaj de la: @{user.get('username', 'Unknown')} (ID: {user['id']})")
|
||||
print(f" User: @{user.get('username', 'Unknown')} (ID: {user['id']})")
|
||||
|
||||
print()
|
||||
print(f" Mesaj: \"{text[:60]}\"")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("CONFIGURARE .env:")
|
||||
print("=" * 60)
|
||||
print("\n💡 Pentru configurare .env:")
|
||||
print()
|
||||
|
||||
# Recomandări
|
||||
for chat_id, chat_data in seen_chats.items():
|
||||
for chat_id, chat in seen_chats.items():
|
||||
if chat_id < 0: # Grup
|
||||
print(f"# Pentru grup (notificări + comenzi):")
|
||||
print(f"\n# Grup: {chat.get('title', 'Unknown')}")
|
||||
print(f"TELEGRAM_CHAT_ID={chat_id}")
|
||||
else: # DM
|
||||
print(f"# Pentru DM:")
|
||||
print(f"\n# DM")
|
||||
print(f"TELEGRAM_CHAT_ID={chat_id}")
|
||||
|
||||
print()
|
||||
print("# User IDs autorizați (pot rula /scrape):")
|
||||
print("TELEGRAM_ALLOWED_USER_IDS=", end="")
|
||||
if user_ids:
|
||||
user_ids_str = ",".join(str(uid) for uid in sorted(user_ids))
|
||||
print(f"\n# User IDs autorizați")
|
||||
print(f"TELEGRAM_ALLOWED_USER_IDS={user_ids_str}")
|
||||
|
||||
# Colectează user IDs unice
|
||||
user_ids = set()
|
||||
for update in results:
|
||||
if 'message' in update:
|
||||
user_id = update['message']['from']['id']
|
||||
user_ids.add(user_id)
|
||||
|
||||
print(",".join(str(uid) for uid in sorted(user_ids)))
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -45,6 +45,13 @@ class EmailNotifier:
|
||||
logging.error("Email configuration incomplete")
|
||||
return False
|
||||
|
||||
# Check if SEND_AS_ZIP flag is set (from Telegram bot /scrape_zip command)
|
||||
send_as_zip = os.getenv('SEND_AS_ZIP', 'false').lower() == 'true'
|
||||
|
||||
if send_as_zip:
|
||||
logging.info("SEND_AS_ZIP flag detected - sending email with ZIP archive")
|
||||
return self._send_with_zip(files, accounts)
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = self.config.EMAIL_FROM
|
||||
@@ -138,6 +145,64 @@ class EmailNotifier:
|
||||
logging.error(f"Failed to send email with ZIP: {e}")
|
||||
return False
|
||||
|
||||
def send_existing_zip(self, zip_path: Path, accounts: list) -> bool:
|
||||
"""
|
||||
Send email with existing ZIP file
|
||||
|
||||
Args:
|
||||
zip_path: Path to existing ZIP file
|
||||
accounts: List of account data with balances
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not self.enabled:
|
||||
logging.info("Email notifications disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Validate config
|
||||
if not all([self.config.SMTP_SERVER, self.config.EMAIL_FROM, self.config.EMAIL_TO]):
|
||||
logging.error("Email configuration incomplete")
|
||||
return False
|
||||
|
||||
if not zip_path.exists():
|
||||
logging.error(f"ZIP file not found: {zip_path}")
|
||||
return False
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = self.config.EMAIL_FROM
|
||||
msg['To'] = self.config.EMAIL_TO
|
||||
msg['Subject'] = f'BTGO Export (ZIP) - {datetime.now().strftime("%Y-%m-%d %H:%M")}'
|
||||
|
||||
# Email body
|
||||
body = self._create_email_body([str(zip_path)], accounts, is_zip=True)
|
||||
msg.attach(MIMEText(body, 'html'))
|
||||
|
||||
# Attach ZIP file
|
||||
with open(zip_path, 'rb') as f:
|
||||
part = MIMEBase('application', 'zip')
|
||||
part.set_payload(f.read())
|
||||
encoders.encode_base64(part)
|
||||
part.add_header('Content-Disposition', f'attachment; filename={zip_path.name}')
|
||||
msg.attach(part)
|
||||
|
||||
# Send email
|
||||
logging.info(f"Sending email with existing ZIP to {self.config.EMAIL_TO}...")
|
||||
with smtplib.SMTP(self.config.SMTP_SERVER, self.config.SMTP_PORT) as server:
|
||||
server.starttls()
|
||||
if self.config.SMTP_USERNAME and self.config.SMTP_PASSWORD:
|
||||
server.login(self.config.SMTP_USERNAME, self.config.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
logging.info(f"✓ Email with ZIP sent successfully to {self.config.EMAIL_TO}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send email with existing ZIP: {e}")
|
||||
return False
|
||||
|
||||
def _create_email_body(self, files: List[str], accounts: list, is_zip: bool = False) -> str:
|
||||
"""Create HTML email body"""
|
||||
file_list = '<br>'.join([f'• {Path(f).name}' for f in files])
|
||||
|
||||
@@ -9,12 +9,15 @@ import io
|
||||
import subprocess
|
||||
import logging
|
||||
import json
|
||||
import csv
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import glob
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from config import Config
|
||||
from notifications import EmailNotifier
|
||||
|
||||
# Load environment
|
||||
load_dotenv()
|
||||
@@ -43,14 +46,19 @@ class TelegramTriggerBot:
|
||||
self.bot_token = BOT_TOKEN
|
||||
self.allowed_users = [int(uid.strip()) for uid in ALLOWED_USER_IDS
|
||||
if uid.strip() and not uid.strip().startswith('#')]
|
||||
self.allowed_group_id = CHAT_ID.strip() if CHAT_ID else None
|
||||
self.base_url = f"https://api.telegram.org/bot{self.bot_token}"
|
||||
self.last_update_id = 0
|
||||
self.poll_timeout = POLL_TIMEOUT
|
||||
|
||||
# State management pentru selecție interactivă conturi
|
||||
self.pending_account_selection = {} # {user_id: {'accounts': [...], 'timestamp': ...}}
|
||||
|
||||
if not self.bot_token:
|
||||
raise ValueError("TELEGRAM_BOT_TOKEN nu este setat în .env!")
|
||||
|
||||
logging.info(f"Bot inițializat. Useri autorizați: {self.allowed_users}")
|
||||
logging.info(f"Grup autorizat: {self.allowed_group_id}")
|
||||
logging.info(f"Long polling timeout: {self.poll_timeout}s")
|
||||
|
||||
# Înregistrare comenzi în meniul Telegram
|
||||
@@ -63,6 +71,9 @@ class TelegramTriggerBot:
|
||||
commands = [
|
||||
{"command": "scrape", "description": "Rulează scraper-ul BTGO"},
|
||||
{"command": "scrape_zip", "description": "Rulează scraper + trimite ZIP"},
|
||||
{"command": "scrape_solduri", "description": "Extrage doar soldurile (fără CSV)"},
|
||||
{"command": "solduri", "description": "Afișează ultimul fișier solduri"},
|
||||
{"command": "tranzactii", "description": "Afișează tranzacții recente din cont"},
|
||||
{"command": "zip", "description": "Trimite ultimele fișiere ca ZIP"},
|
||||
{"command": "status", "description": "Status sistem"},
|
||||
{"command": "help", "description": "Ajutor comenzi"}
|
||||
@@ -100,17 +111,62 @@ class TelegramTriggerBot:
|
||||
response = requests.post(url, data=data, files=files)
|
||||
return response.json()
|
||||
|
||||
def is_user_allowed(self, user_id):
|
||||
"""Verifică dacă user-ul are permisiune"""
|
||||
if not self.allowed_users: # Dacă lista e goală, permite oricui
|
||||
return True
|
||||
return user_id in self.allowed_users
|
||||
def is_member_of_group(self, user_id, group_id):
|
||||
"""Verifică dacă user_id este membru al group_id prin Telegram API"""
|
||||
try:
|
||||
url = f"{self.base_url}/getChatMember"
|
||||
params = {
|
||||
'chat_id': group_id,
|
||||
'user_id': user_id
|
||||
}
|
||||
response = requests.get(url, params=params, timeout=5)
|
||||
|
||||
def run_scraper(self, chat_id, reply_to_message_id=None, send_as_zip=False):
|
||||
if response.status_code == 200 and response.json().get('ok'):
|
||||
result = response.json().get('result', {})
|
||||
status = result.get('status', '')
|
||||
|
||||
# Statusuri valide: creator, administrator, member
|
||||
if status in ['creator', 'administrator', 'member']:
|
||||
logging.info(f"User {user_id} este membru al grupului {group_id} (status: {status})")
|
||||
return True
|
||||
else:
|
||||
logging.info(f"User {user_id} NU este membru al grupului {group_id} (status: {status})")
|
||||
return False
|
||||
else:
|
||||
logging.warning(f"Eroare verificare membership: {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"Excepție verificare membership pentru user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def is_user_allowed(self, user_id):
|
||||
"""Verifică dacă user-ul are permisiune (whitelist sau membru al grupului autorizat)"""
|
||||
# 1. Verifică dacă e în whitelist explicit
|
||||
if user_id in self.allowed_users:
|
||||
logging.info(f"User {user_id} autorizat prin TELEGRAM_ALLOWED_USER_IDS")
|
||||
return True
|
||||
|
||||
# 2. Verifică dacă e membru al grupului autorizat
|
||||
if self.allowed_group_id:
|
||||
if self.is_member_of_group(user_id, self.allowed_group_id):
|
||||
logging.info(f"User {user_id} autorizat prin membership în grup {self.allowed_group_id}")
|
||||
return True
|
||||
|
||||
# 3. Dacă ambele liste sunt goale, permite oricui (backwards compatible)
|
||||
if not self.allowed_users and not self.allowed_group_id:
|
||||
logging.warning("Nicio restricție configurată - bot DESCHIS pentru toți userii!")
|
||||
return True
|
||||
|
||||
# 4. Altfel, respinge
|
||||
logging.warning(f"User {user_id} RESPINS - nu e în whitelist și nu e membru al grupului")
|
||||
return False
|
||||
|
||||
def run_scraper(self, chat_id, reply_to_message_id=None, send_as_zip=False, balances_only=False):
|
||||
"""Execută scraper-ul"""
|
||||
# Trimite mesaj inițial și salvează message_id pentru editare ulterioară
|
||||
zip_msg = " (arhiva ZIP)" if send_as_zip else ""
|
||||
response = self.send_message(chat_id, f"*BTGO Scraper pornit{zip_msg}*\n\nAsteapta 2FA pe telefon.", reply_to_message_id)
|
||||
balances_msg = " - DOAR SOLDURI" if balances_only else ""
|
||||
response = self.send_message(chat_id, f"*BTGO Scraper pornit{zip_msg}{balances_msg}*\n\nAsteapta 2FA pe telefon.", reply_to_message_id)
|
||||
message_id = None
|
||||
try:
|
||||
message_id = response.json()['result']['message_id']
|
||||
@@ -119,8 +175,46 @@ class TelegramTriggerBot:
|
||||
logging.warning("Nu am putut salva message_id pentru progress updates")
|
||||
|
||||
try:
|
||||
# Șterge fișierele CSV, ZIP și PNG anterioare
|
||||
data_dir = Path('data')
|
||||
if data_dir.exists():
|
||||
deleted_count = 0
|
||||
|
||||
# Șterge CSV-uri de solduri
|
||||
for f in data_dir.glob('solduri_*.csv'):
|
||||
f.unlink()
|
||||
deleted_count += 1
|
||||
logging.info(f"Șters: {f.name}")
|
||||
|
||||
# Șterge CSV-uri de tranzacții
|
||||
for f in data_dir.glob('tranzactii_*.csv'):
|
||||
f.unlink()
|
||||
deleted_count += 1
|
||||
logging.info(f"Șters: {f.name}")
|
||||
|
||||
# Șterge JSON-uri
|
||||
for f in data_dir.glob('solduri_*.json'):
|
||||
f.unlink()
|
||||
deleted_count += 1
|
||||
logging.info(f"Șters: {f.name}")
|
||||
|
||||
# Șterge ZIP-uri
|
||||
for f in data_dir.glob('btgo_export_*.zip'):
|
||||
f.unlink()
|
||||
deleted_count += 1
|
||||
logging.info(f"Șters: {f.name}")
|
||||
|
||||
# Șterge PNG-uri (screenshot-uri Playwright)
|
||||
for f in data_dir.glob('*.png'):
|
||||
f.unlink()
|
||||
deleted_count += 1
|
||||
logging.info(f"Șters: {f.name}")
|
||||
|
||||
if deleted_count > 0:
|
||||
logging.info(f"Total {deleted_count} fisiere sterse inainte de scraping")
|
||||
|
||||
# Rulează scraper-ul
|
||||
logging.info(f"Pornire scraper (send_as_zip={send_as_zip})...")
|
||||
logging.info(f"Pornire scraper (send_as_zip={send_as_zip}, balances_only={balances_only})...")
|
||||
|
||||
# Prepare environment with global playwright path + Telegram progress info
|
||||
env = os.environ.copy()
|
||||
@@ -136,6 +230,11 @@ class TelegramTriggerBot:
|
||||
if send_as_zip:
|
||||
env['SEND_AS_ZIP'] = 'true'
|
||||
logging.info("Mod ZIP activat - va trimite arhivă ZIP")
|
||||
|
||||
# Dacă balances_only, comunică să nu descarce tranzacții
|
||||
if balances_only:
|
||||
env['BALANCES_ONLY'] = 'true'
|
||||
logging.info("Mod DOAR SOLDURI activat - nu va descărca tranzacții")
|
||||
else:
|
||||
logging.warning("No message_id available for progress updates")
|
||||
|
||||
@@ -170,6 +269,374 @@ class TelegramTriggerBot:
|
||||
logging.error(f"Eroare execuție: {e}")
|
||||
self.send_message(chat_id, f"*EROARE EXECUTIE*\n\n```\n{str(e)}\n```", reply_to_message_id)
|
||||
|
||||
def show_cached_balances(self, chat_id, reply_to_message_id=None):
|
||||
"""Afișează soldurile din cel mai recent fișier solduri.csv"""
|
||||
try:
|
||||
data_dir = Path('data')
|
||||
|
||||
if not data_dir.exists():
|
||||
self.send_message(chat_id, "*EROARE*\n\nDirectorul 'data' nu există!", reply_to_message_id)
|
||||
return
|
||||
|
||||
# Găsește ultimul fișier solduri
|
||||
solduri_files = sorted(data_dir.glob('solduri_*.csv'), key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
|
||||
if not solduri_files:
|
||||
self.send_message(chat_id, "*EROARE*\n\nNu s-au găsit fișiere solduri!", reply_to_message_id)
|
||||
return
|
||||
|
||||
latest_solduri = solduri_files[0]
|
||||
solduri_time = latest_solduri.stat().st_mtime
|
||||
file_datetime = datetime.fromtimestamp(solduri_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Citește fișierul CSV
|
||||
accounts = []
|
||||
with open(latest_solduri, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
accounts.append({
|
||||
'nume_cont': row['nume_cont'],
|
||||
'sold': float(row['sold']),
|
||||
'moneda': row['moneda']
|
||||
})
|
||||
|
||||
# Construiește mesaj cu solduri
|
||||
total_ron = sum(acc['sold'] for acc in accounts if acc.get('moneda') == 'RON')
|
||||
|
||||
message = f"*SOLDURI BANCARE*\n\n"
|
||||
message += f"Data: {file_datetime}\n"
|
||||
message += f"Conturi: {len(accounts)}\n\n"
|
||||
|
||||
for acc in accounts:
|
||||
nume = acc['nume_cont']
|
||||
sold = acc['sold']
|
||||
moneda = acc['moneda']
|
||||
message += f" • {nume}: {sold:,.2f} {moneda}\n"
|
||||
|
||||
message += f"\n*TOTAL: {total_ron:,.2f} RON*"
|
||||
|
||||
self.send_message(chat_id, message, reply_to_message_id)
|
||||
logging.info(f"Afișat solduri cached din {latest_solduri.name}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Eroare show_cached_balances: {e}", exc_info=True)
|
||||
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
|
||||
|
||||
def show_transactions_menu(self, chat_id, user_id, reply_to_message_id=None):
|
||||
"""Afișează meniu cu conturi disponibile pentru selecție tranzacții"""
|
||||
try:
|
||||
data_dir = Path('data')
|
||||
|
||||
if not data_dir.exists():
|
||||
self.send_message(chat_id, "*EROARE*\n\nDirectorul 'data' nu există!", reply_to_message_id)
|
||||
return
|
||||
|
||||
# Găsește toate fișierele de tranzacții
|
||||
transaction_files = sorted(
|
||||
data_dir.glob('tranzactii_*.csv'),
|
||||
key=lambda x: x.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if not transaction_files:
|
||||
self.send_message(
|
||||
chat_id,
|
||||
"*EROARE*\n\nNu s-au găsit fișiere cu tranzacții!\n\nRulează mai întâi /scrape pentru a descărca tranzacții.",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
# Extrage nume conturi unice din numele fișierelor
|
||||
# Format: tranzactii_Nume_Cont_YYYY-MM-DD_HH-MM-SS.csv
|
||||
accounts = {}
|
||||
for file_path in transaction_files:
|
||||
filename = file_path.stem # fără extensie
|
||||
# Elimină prefixul "tranzactii_" și sufixul timestamp
|
||||
# rsplit('_', 3) split-uiește: ['Nume', 'Cont', 'YYYY-MM-DD', 'HH-MM-SS']
|
||||
parts = filename.replace('tranzactii_', '').rsplit('_', 3)
|
||||
if len(parts) >= 4:
|
||||
# parts[0] = nume de bază, parts[1] = număr cont
|
||||
account_name = f"{parts[0]}_{parts[1]}" # "Nume_Cont"
|
||||
# Convertește Nume_Cont → Nume Cont
|
||||
display_name = account_name.replace('_', ' ')
|
||||
|
||||
# Păstrează doar cel mai recent fișier pentru fiecare cont
|
||||
if display_name not in accounts:
|
||||
accounts[display_name] = file_path
|
||||
|
||||
if not accounts:
|
||||
self.send_message(
|
||||
chat_id,
|
||||
"*EROARE*\n\nNu s-au putut procesa fișierele de tranzacții!",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
# Sortează conturile alfabetic
|
||||
sorted_accounts = sorted(accounts.items())
|
||||
|
||||
# Construiește mesaj
|
||||
message = "*TRANZACTII BANCARE*\n\n"
|
||||
message += "Conturi disponibile:\n\n"
|
||||
|
||||
for idx, (account_name, file_path) in enumerate(sorted_accounts, 1):
|
||||
message += f"{idx}. {account_name}\n"
|
||||
|
||||
message += f"\n*Scrie numarul contului (1-{len(sorted_accounts)}):*\n"
|
||||
message += " • Doar numar (ex: 2) = ultimele 10\n"
|
||||
message += " • Numar + zile (ex: 2 7, 2 30)"
|
||||
|
||||
# Salvează starea pentru user
|
||||
self.pending_account_selection[user_id] = {
|
||||
'accounts': sorted_accounts,
|
||||
'timestamp': datetime.now().timestamp()
|
||||
}
|
||||
|
||||
self.send_message(chat_id, message, reply_to_message_id)
|
||||
logging.info(f"Afișat meniu tranzacții pentru user {user_id}: {len(sorted_accounts)} conturi")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Eroare show_transactions_menu: {e}", exc_info=True)
|
||||
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
|
||||
|
||||
def show_account_transactions(self, chat_id, user_id, account_index, reply_to_message_id=None, period="10"):
|
||||
"""Afișează tranzacții pentru contul selectat (perioada: 10/luna/sapt)"""
|
||||
try:
|
||||
# Verifică dacă userul are o selecție pending
|
||||
if user_id not in self.pending_account_selection:
|
||||
self.send_message(
|
||||
chat_id,
|
||||
"*EROARE*\n\nNu există selecție activă. Folosește /tranzactii pentru a începe.",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
pending_data = self.pending_account_selection[user_id]
|
||||
accounts = pending_data['accounts']
|
||||
|
||||
# Verifică timeout (5 minute)
|
||||
if datetime.now().timestamp() - pending_data['timestamp'] > 300:
|
||||
del self.pending_account_selection[user_id]
|
||||
self.send_message(
|
||||
chat_id,
|
||||
"*TIMEOUT*\n\nSelecția a expirat. Folosește /tranzactii pentru a începe din nou.",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
# Verifică index valid
|
||||
if account_index < 1 or account_index > len(accounts):
|
||||
self.send_message(
|
||||
chat_id,
|
||||
f"*EROARE*\n\nNumăr invalid! Scrie un număr între 1 și {len(accounts)}.",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
# Obține contul selectat
|
||||
account_name, csv_path = accounts[account_index - 1]
|
||||
|
||||
# Verifică dacă fișierul există
|
||||
if not csv_path.exists():
|
||||
del self.pending_account_selection[user_id]
|
||||
self.send_message(
|
||||
chat_id,
|
||||
f"*EROARE*\n\nFișierul pentru contul {account_name} nu mai există!",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
# Citește CSV-ul
|
||||
# Format BT: Primele 17 linii = metadata, linia 18 = header, linia 19+ = date
|
||||
transactions = []
|
||||
account_iban = ""
|
||||
csv_period = "" # Perioada din CSV (nu parametrul funcției!)
|
||||
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Extrage metadate
|
||||
if len(lines) > 8:
|
||||
# Linia 9: Perioada:,29.10.2025-12.11.2025
|
||||
period_line = lines[8].strip()
|
||||
if 'Perioada:' in period_line:
|
||||
csv_period = period_line.split(',')[1] if ',' in period_line else ""
|
||||
|
||||
# Linia 8: Numar cont:,RO32BTRLRONCRT0637236701 RON
|
||||
iban_line = lines[7].strip()
|
||||
if 'Numar cont:' in iban_line:
|
||||
account_iban = iban_line.split(',')[1] if ',' in iban_line else ""
|
||||
|
||||
# Citește tranzacțiile (de la header - linia 18, index 17)
|
||||
if len(lines) > 17:
|
||||
csv_content = ''.join(lines[17:]) # Include header + date
|
||||
reader = csv.DictReader(csv_content.splitlines())
|
||||
|
||||
for row in reader:
|
||||
if row.get('Data tranzactie'): # Skip linii goale
|
||||
transactions.append(row)
|
||||
|
||||
# Șterge selecția pending
|
||||
del self.pending_account_selection[user_id]
|
||||
|
||||
if not transactions:
|
||||
self.send_message(
|
||||
chat_id,
|
||||
f"*{account_name}*\n\nNu există tranzacții în acest fișier.",
|
||||
reply_to_message_id
|
||||
)
|
||||
return
|
||||
|
||||
# Filtrează tranzacțiile în funcție de perioada selectată (nr zile)
|
||||
from collections import defaultdict
|
||||
|
||||
recent_transactions = []
|
||||
period_description = ""
|
||||
|
||||
# Convertește period în număr de zile
|
||||
try:
|
||||
num_days = int(period)
|
||||
except (ValueError, TypeError):
|
||||
num_days = 10 # Default
|
||||
|
||||
if num_days <= 0:
|
||||
num_days = 10 # Sigură
|
||||
|
||||
# Filtrează pe bază de zile
|
||||
if num_days == 10:
|
||||
# Optimizare: ultimele 10 tranzacții direct
|
||||
recent_transactions = transactions[:10]
|
||||
period_description = "Ultimele 10 tranzactii"
|
||||
else:
|
||||
# Filtrare pe bază de dată
|
||||
now = datetime.now()
|
||||
cutoff_date = now - timedelta(days=num_days)
|
||||
|
||||
for tx in transactions:
|
||||
tx_date_str = tx.get('Data tranzactie', '')
|
||||
if tx_date_str:
|
||||
try:
|
||||
tx_date = datetime.strptime(tx_date_str, '%Y-%m-%d')
|
||||
if tx_date >= cutoff_date:
|
||||
recent_transactions.append(tx)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
period_description = f"Ultimele {num_days} zile"
|
||||
|
||||
# Grupează tranzacțiile pe date
|
||||
transactions_by_date = defaultdict(list)
|
||||
|
||||
for tx in recent_transactions:
|
||||
date = tx.get('Data tranzactie', '')
|
||||
if date:
|
||||
transactions_by_date[date].append(tx)
|
||||
|
||||
# Construiește mesaj
|
||||
message = f"*TRANZACTII - {account_name}*\n\n"
|
||||
|
||||
if period_description:
|
||||
message += f"Perioada: {period_description}\n"
|
||||
|
||||
message += f"Total: {len(recent_transactions)} tranzactii\n"
|
||||
message += "=" * 30 + "\n\n"
|
||||
|
||||
# Sortează datele descrescător (cele mai recente primul)
|
||||
sorted_dates = sorted(transactions_by_date.keys(), reverse=True)
|
||||
|
||||
for date in sorted_dates:
|
||||
# Header pentru dată
|
||||
message += f"*{date}*\n"
|
||||
|
||||
# Tranzacțiile din acea zi
|
||||
for tx in transactions_by_date[date]:
|
||||
description = tx.get('Descriere', '')
|
||||
debit = tx.get('Debit', '').strip().replace('"', '').replace(',', '')
|
||||
credit = tx.get('Credit', '').strip().replace('"', '').replace(',', '')
|
||||
|
||||
# Extrage nume mai inteligent din descriere
|
||||
display_name = description
|
||||
desc_parts = description.split(';')
|
||||
|
||||
if len(desc_parts) > 2:
|
||||
# Caz special: Plăți POS - extrage comerciantul din Parts[1]
|
||||
if 'POS' in desc_parts[0] and len(desc_parts) > 1:
|
||||
# Căutăm pattern: "TID:XXXXXXX <COMERCIANT> <REST>"
|
||||
import re
|
||||
tid_match = re.search(r'TID:\S+\s+(.+?)\s{2,}', desc_parts[1])
|
||||
if tid_match:
|
||||
candidate = tid_match.group(1).strip()
|
||||
else:
|
||||
# Fallback: încearcă să extragă după TID până la două spații
|
||||
if 'TID:' in desc_parts[1]:
|
||||
after_tid = desc_parts[1].split('TID:')[1]
|
||||
# Skip ID-ul TID și ia textul până la două spații consecutive
|
||||
parts_after = after_tid.split(None, 1) # Split la primul spațiu
|
||||
if len(parts_after) > 1:
|
||||
# Ia textul până la " " sau până la sfârșitul
|
||||
merchant_text = parts_after[1]
|
||||
double_space_idx = merchant_text.find(' ')
|
||||
if double_space_idx > 0:
|
||||
candidate = merchant_text[:double_space_idx].strip()
|
||||
else:
|
||||
candidate = merchant_text.strip()
|
||||
else:
|
||||
candidate = desc_parts[2].strip()
|
||||
else:
|
||||
candidate = desc_parts[2].strip()
|
||||
else:
|
||||
# Încearcă part[2] (de obicei numele)
|
||||
candidate = desc_parts[2].strip()
|
||||
|
||||
# Dacă part[2] este doar număr/scurt/REF, încearcă part[3]
|
||||
if (candidate.isdigit() or
|
||||
len(candidate) < 3 or
|
||||
candidate.startswith('REF:')):
|
||||
if len(desc_parts) > 3:
|
||||
candidate = desc_parts[3].strip()
|
||||
|
||||
# Dacă tot e invalid, folosește tipul tranzacției (part[0])
|
||||
if len(candidate) < 3 or candidate.startswith('REF:'):
|
||||
candidate = desc_parts[0].strip()
|
||||
|
||||
display_name = candidate
|
||||
|
||||
# Truncate dacă prea lung
|
||||
if len(display_name) > 35:
|
||||
display_name = display_name[:32] + "..."
|
||||
|
||||
# Escape caractere speciale Markdown pentru Telegram
|
||||
# Caracterele care trebuie escapate: _ * [ ] ( ) ~ ` > # + - = | { } . !
|
||||
markdown_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
|
||||
for char in markdown_chars:
|
||||
display_name = display_name.replace(char, '\\' + char)
|
||||
|
||||
# Determină suma (debit poate avea deja minus în CSV)
|
||||
if credit:
|
||||
amount_str = f"+{credit}"
|
||||
elif debit:
|
||||
# Elimină minus-ul dacă există deja
|
||||
debit_clean = debit.lstrip('-')
|
||||
amount_str = f"-{debit_clean}"
|
||||
else:
|
||||
amount_str = "0.00"
|
||||
|
||||
# Format compact: " • Nume: +suma RON"
|
||||
message += f" {display_name}: {amount_str} RON\n"
|
||||
|
||||
message += "\n"
|
||||
|
||||
self.send_message(chat_id, message, reply_to_message_id)
|
||||
logging.info(f"Afișat {len(recent_transactions)} tranzacții pentru {account_name}")
|
||||
|
||||
except Exception as e:
|
||||
# Curăță selecția în caz de eroare
|
||||
if user_id in self.pending_account_selection:
|
||||
del self.pending_account_selection[user_id]
|
||||
|
||||
logging.error(f"Eroare show_account_transactions: {e}", exc_info=True)
|
||||
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
|
||||
|
||||
def send_zip_files(self, chat_id, reply_to_message_id=None):
|
||||
"""Trimite ultimele fișiere ca arhivă ZIP"""
|
||||
try:
|
||||
@@ -238,7 +705,7 @@ class TelegramTriggerBot:
|
||||
return
|
||||
|
||||
# Construiește mesaj cu solduri
|
||||
caption = f"📦 *BTGO Export (ZIP)*\n\n"
|
||||
caption = f"*BTGO Export (ZIP)*\n\n"
|
||||
caption += f"Timp: {datetime.fromtimestamp(solduri_time).strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
caption += f"Dimensiune: {zip_size:.2f} MB\n"
|
||||
caption += f"Fișiere: {len(files_to_zip)}\n\n"
|
||||
@@ -256,7 +723,7 @@ class TelegramTriggerBot:
|
||||
caption += f"Conturi: {len(transaction_files)}"
|
||||
|
||||
# Trimite ZIP-ul
|
||||
self.send_message(chat_id, "📦 *Creare arhivă ZIP...*", reply_to_message_id)
|
||||
self.send_message(chat_id, "*Creare arhivă ZIP...*", reply_to_message_id)
|
||||
|
||||
url = f"{self.base_url}/sendDocument"
|
||||
with open(zip_path, 'rb') as f:
|
||||
@@ -277,6 +744,21 @@ class TelegramTriggerBot:
|
||||
logging.error(f"Eroare trimitere ZIP: {response.text}")
|
||||
self.send_message(chat_id, f"*EROARE*\n\nNu s-a putut trimite arhiva.", reply_to_message_id)
|
||||
|
||||
# Trimite și pe email dacă este configurat
|
||||
try:
|
||||
config = Config()
|
||||
if config.EMAIL_ENABLED:
|
||||
email_notifier = EmailNotifier(config)
|
||||
logging.info("Trimitere ZIP pe email...")
|
||||
if email_notifier.send_existing_zip(zip_path, accounts_data):
|
||||
logging.info("✓ ZIP trimis cu succes pe email")
|
||||
else:
|
||||
logging.warning("Nu s-a putut trimite ZIP-ul pe email")
|
||||
else:
|
||||
logging.info("Email notifications disabled - skipping email")
|
||||
except Exception as e:
|
||||
logging.error(f"Eroare trimitere ZIP pe email: {e}")
|
||||
|
||||
# Șterge fișierul ZIP temporar
|
||||
zip_path.unlink()
|
||||
|
||||
@@ -308,6 +790,25 @@ class TelegramTriggerBot:
|
||||
self.send_message(chat_id, "*ACCES INTERZIS*\n\nNu ai permisiunea sa folosesti acest bot.", message_id)
|
||||
return
|
||||
|
||||
# Procesează răspuns numeric pentru selecție cont tranzacții
|
||||
if user_id in self.pending_account_selection:
|
||||
# Parse număr cont + opțional nr zile
|
||||
# Formate acceptate: "2" (default 10), "2 7" (7 zile), "2 30" (30 zile)
|
||||
parts = text.strip().split()
|
||||
|
||||
|
||||
if len(parts) >= 1 and parts[0].isdigit():
|
||||
account_index = int(parts[0])
|
||||
|
||||
# Determină numărul de zile
|
||||
period = "10" # default: ultimele 10 tranzacții
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
period = parts[1] # număr de zile
|
||||
|
||||
logging.info(f"Răspuns selecție cont: {account_index}, perioada: {period} zile de la user {user_id}")
|
||||
self.show_account_transactions(chat_id, user_id, account_index, message_id, period)
|
||||
return
|
||||
|
||||
# Procesează comenzi
|
||||
if text == '/start':
|
||||
welcome_msg = "*BTGO Scraper Trigger Bot*\n\n"
|
||||
@@ -315,8 +816,11 @@ class TelegramTriggerBot:
|
||||
welcome_msg += f"Bot activ in grupul *{chat_title}*\n\n"
|
||||
welcome_msg += (
|
||||
"Comenzi disponibile:\n"
|
||||
"`/scrape` - Ruleaza scraper-ul\n"
|
||||
"`/scrape` - Ruleaza scraper-ul complet\n"
|
||||
"`/scrape_zip` - Ruleaza scraper + trimite ZIP\n"
|
||||
"`/scrape_solduri` - Extrage doar soldurile (rapid)\n"
|
||||
"`/solduri` - Afiseaza ultimul fisier solduri\n"
|
||||
"`/tranzactii` - Afiseaza tranzactii recente\n"
|
||||
"`/zip` - Trimite ultimele fisiere ca ZIP\n"
|
||||
"`/status` - Status sistem\n"
|
||||
"`/help` - Ajutor"
|
||||
@@ -331,6 +835,18 @@ class TelegramTriggerBot:
|
||||
logging.info(f"Comandă /scrape_zip primită în {context}")
|
||||
self.run_scraper(chat_id, message_id, send_as_zip=True)
|
||||
|
||||
elif text == '/scrape_solduri':
|
||||
logging.info(f"Comandă /scrape_solduri primită în {context}")
|
||||
self.run_scraper(chat_id, message_id, balances_only=True)
|
||||
|
||||
elif text == '/solduri':
|
||||
logging.info(f"Comandă /solduri primită în {context}")
|
||||
self.show_cached_balances(chat_id, message_id)
|
||||
|
||||
elif text == '/tranzactii':
|
||||
logging.info(f"Comandă /tranzactii primită în {context}")
|
||||
self.show_transactions_menu(chat_id, user_id, message_id)
|
||||
|
||||
elif text == '/zip':
|
||||
logging.info(f"Comandă /zip primită în {context}")
|
||||
self.send_zip_files(chat_id, message_id)
|
||||
@@ -364,20 +880,26 @@ class TelegramTriggerBot:
|
||||
"*COMENZI:*\n"
|
||||
"`/scrape` - Ruleaza scraper + trimite fisiere individuale\n"
|
||||
"`/scrape_zip` - Ruleaza scraper + trimite arhiva ZIP\n"
|
||||
"`/scrape_solduri` - Extrage doar soldurile (fara CSV tranzactii)\n"
|
||||
"`/solduri` - Afiseaza ultimul fisier solduri (instant)\n"
|
||||
"`/tranzactii` - Afiseaza tranzactii recente din cont (interactiv)\n"
|
||||
"`/zip` - Trimite ultimele fisiere ca arhiva ZIP (fara scraping)\n"
|
||||
"`/status` - Informatii sistem\n"
|
||||
"`/help` - Acest mesaj\n\n"
|
||||
"*GHID SCRAPER:*\n"
|
||||
"1. Trimite `/scrape` sau `/scrape_zip`\n"
|
||||
"1. Trimite `/scrape`, `/scrape_zip` sau `/scrape_solduri`\n"
|
||||
"2. Asteapta notificarea de 2FA pe telefon\n"
|
||||
"3. Aproba in aplicatia George\n"
|
||||
"4. Primesti fisierele automat\n\n"
|
||||
"*DIFERENTE:*\n"
|
||||
"• `/scrape` - Fisiere individuale (CSV + JSON)\n"
|
||||
"• `/scrape_zip` - Un singur ZIP cu toate fisierele\n"
|
||||
"• `/zip` - Rapid, foloseste datele existente\n\n"
|
||||
"• `/scrape_solduri` - Doar solduri (RAPID - fara CSV tranzactii)\n"
|
||||
"• `/solduri` - Vizualizare rapida (fara 2FA)\n"
|
||||
"• `/zip` - Fisiere existente (fara scraping)\n\n"
|
||||
"*NOTE:*\n"
|
||||
"- Scraper-ul ruleaza ~2-3 minute\n"
|
||||
"- Scraper complet: ~2-3 minute\n"
|
||||
"- Scraper solduri: ~30-40 secunde\n"
|
||||
"- VM-ul trebuie sa aiba browser vizibil"
|
||||
)
|
||||
self.send_message(chat_id, help_msg, message_id)
|
||||
|
||||
Reference in New Issue
Block a user