triangle-api 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: triangle-api
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Unofficial Python wrapper for Canadian Tire Triangle Mastercard API
5
5
  Home-page: https://github.com/diogobas/triangle-api
6
6
  Author: Diogo Bastos
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="triangle-api", # Nome que aparecerá no PyPI
8
- version="0.1.3",
8
+ version="0.1.5",
9
9
  author="Diogo Bastos",
10
10
  author_email="seu-email@exemplo.com", # Opcional
11
11
  description="Unofficial Python wrapper for Canadian Tire Triangle Mastercard API",
@@ -0,0 +1,4 @@
1
+ from .client import TriangleClient
2
+
3
+ __version__ = "0.1.5"
4
+ __all__ = ["TriangleClient"]
@@ -0,0 +1,173 @@
1
+ from playwright.sync_api import sync_playwright
2
+ import os
3
+ import time
4
+ import hashlib
5
+ import json
6
+ from datetime import datetime
7
+
8
+ class TriangleClient:
9
+ def __init__(self, username=None, password=None, session_path="auth_state.json"):
10
+ self.username = username
11
+ self.password = password
12
+ self.session_path = session_path
13
+ self.account_data = None
14
+
15
+ def _get_csrf(self, context):
16
+ """Auxiliar para extrair o token CSRF dos cookies."""
17
+ cookies = context.cookies()
18
+ return next((c['value'] for c in cookies if c['name'] == 'csrftoken'), "")
19
+
20
+ def _fetch_account_data(self, page, csrf_token):
21
+ """Faz a chamada manual do retrieveAccount com CSRF."""
22
+ js_get_account = """
23
+ async (csrf) => {
24
+ const r = await fetch('/dash/v1/account/retrieveAccount', {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ 'CSRF-Token': csrf,
29
+ 'csrfToken': csrf
30
+ },
31
+ body: JSON.stringify({})
32
+ });
33
+ return r.ok ? await r.json() : null;
34
+ }
35
+ """
36
+ return page.evaluate(js_get_account, csrf_token)
37
+
38
+ def login(self, headless=True):
39
+ with sync_playwright() as p:
40
+ browser = p.chromium.launch(headless=headless)
41
+ has_session = os.path.exists(self.session_path)
42
+ storage = self.session_path if has_session else None
43
+
44
+ context = browser.new_context(
45
+ viewport={'width': 1280, 'height': 800},
46
+ storage_state=storage,
47
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
48
+ )
49
+ page = context.new_page()
50
+
51
+ print("[API] Acessando o portal...")
52
+ page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", timeout=60000)
53
+
54
+ # Se cair no login, faz o processo de autenticação
55
+ if "login" in page.url.lower():
56
+ if not self.username or not self.password:
57
+ browser.close()
58
+ return "NEEDS_CREDENTIALS"
59
+
60
+ print("[API] Sessão expirada. Autenticando...")
61
+ try:
62
+ # Lida com cookies
63
+ if page.is_visible("#onetrust-accept-btn-handler"):
64
+ page.click("#onetrust-accept-btn-handler", timeout=5000)
65
+
66
+ page.wait_for_selector("#username", timeout=30000)
67
+ page.fill("#username", self.username)
68
+ page.fill("#password", self.password)
69
+ page.focus("#password")
70
+ page.keyboard.press("Enter")
71
+
72
+ # Espera sair do login
73
+ page.wait_for_function("() => !window.location.href.includes('login')", timeout=45000)
74
+
75
+ if "challenge" in page.url or "otp" in page.url:
76
+ sms_code = input("\n[MFA] Digite o código SMS: ")
77
+ page.get_by_role("textbox").first.fill(sms_code)
78
+ page.keyboard.press("Enter")
79
+ page.wait_for_url("**/Summary.html", timeout=45000)
80
+ except Exception as e:
81
+ print(f"[ERRO] Falha no login: {e}")
82
+ browser.close()
83
+ return None
84
+
85
+ # CAPTURA DE DADOS (Saldo)
86
+ print("[API] Capturando dados financeiros...")
87
+ try:
88
+ page.wait_for_load_state("domcontentloaded")
89
+ csrf = self._get_csrf(context)
90
+ self.account_data = self._fetch_account_data(page, csrf)
91
+
92
+ if self.account_data:
93
+ context.storage_state(path=self.session_path)
94
+ print("[API] Dados capturados com sucesso.")
95
+ except Exception as e:
96
+ print(f"[AVISO] Erro na captura de saldo: {e}")
97
+
98
+ browser.close()
99
+ return self.account_data
100
+
101
+ def get_transactions(self, start_date=None, headless=True):
102
+ today = datetime.today()
103
+ if not start_date:
104
+ start_date = today.replace(day=1).strftime('%Y-%m-%d')
105
+
106
+ with sync_playwright() as p:
107
+ browser = p.chromium.launch(headless=headless)
108
+ context = browser.new_context(storage_state=self.session_path)
109
+ page = context.new_page()
110
+
111
+ print("[API] Sincronizando para transações...")
112
+ page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", wait_until="domcontentloaded")
113
+
114
+ csrf = self._get_csrf(context)
115
+
116
+ # Se o account_data sumiu da memória, pegamos de novo agora que temos o CSRF
117
+ if not self.account_data:
118
+ self.account_data = self._fetch_account_data(page, csrf)
119
+
120
+ if not self.account_data:
121
+ browser.close()
122
+ return []
123
+
124
+ js_tx_script = """
125
+ async (params) => {
126
+ const call = async (cat, stmt = null) => {
127
+ const body = { category: cat, transientReference: params.transient };
128
+ if (stmt) body.statementDate = stmt;
129
+ const r = await fetch('/dash/v1/account/retrieveTransactions', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json', 'CSRF-Token': params.csrf, 'csrfToken': params.csrf },
132
+ body: JSON.stringify(body)
133
+ });
134
+ return r.ok ? await r.json() : { transactions: [] };
135
+ };
136
+ const auth = await call('AUTHORIZED');
137
+ const post = await call('POSTED');
138
+ const stmt = params.lastStmt ? await call('STATEMENTED', params.lastStmt) : { transactions: [] };
139
+ return [...(auth.transactions || []), ...(post.transactions || []), ...(stmt.transactions || [])];
140
+ }
141
+ """
142
+
143
+ raw_txs = page.evaluate(js_tx_script, {
144
+ "transient": self.account_data.get("transientReference"),
145
+ "lastStmt": self.account_data.get("lastStatementDate"),
146
+ "csrf": csrf
147
+ })
148
+ browser.close()
149
+
150
+ # Filtro e Deduplicação
151
+ unique_txs = {}
152
+ for t in raw_txs:
153
+ d = t.get("tranDate", "")
154
+ if d >= start_date:
155
+ key = f"{d}|{t.get('merchant')}|{t.get('amount')}"
156
+ unique_txs[key] = t
157
+ return list(unique_txs.values())
158
+
159
+ def get_normalized_transactions(self, start_date=None, headless=True):
160
+ txs = self.get_transactions(start_date=start_date, headless=headless)
161
+ normalized = []
162
+ for t in txs:
163
+ date, payee, amount = t.get("tranDate"), t.get("merchant", "Unknown"), float(t.get("amount", 0.0))
164
+ ext_id = hashlib.md5(f"{date}|{payee}|{amount}".encode()).hexdigest()
165
+ normalized.append({
166
+ "date": date, "payee": payee, "amount": amount, "currency": "cad",
167
+ "external_id": ext_id, "status": "cleared" if t.get("category") in ["POSTED", "STATEMENTED"] else "uncleared",
168
+ "notes": f"Type: {t.get('type')} | Imported via Triangle API"
169
+ })
170
+ return sorted(normalized, key=lambda x: x["date"], reverse=True)
171
+
172
+ def get_balance(self):
173
+ return self.account_data.get("currentBalanceAmt", 0.0) if self.account_data else "N/A"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: triangle-api
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Unofficial Python wrapper for Canadian Tire Triangle Mastercard API
5
5
  Home-page: https://github.com/diogobas/triangle-api
6
6
  Author: Diogo Bastos
@@ -1,4 +0,0 @@
1
- from .client import TriangleClient
2
-
3
- __version__ = "0.1.3"
4
- __all__ = ["TriangleClient"]
@@ -1,220 +0,0 @@
1
- from playwright.sync_api import sync_playwright
2
- import os
3
- import time
4
- import hashlib
5
- from datetime import datetime
6
-
7
- class TriangleClient:
8
- def __init__(self, username=None, password=None, session_path="auth_state.json"):
9
- self.username = username
10
- self.password = password
11
- self.session_path = session_path
12
- self.account_data = None
13
-
14
- def login(self, headless=True):
15
- with sync_playwright() as p:
16
- # 1. Configuração do Navegador
17
- browser = p.chromium.launch(headless=headless)
18
-
19
- # Verifica se existe sessão salva para tentar restaurar
20
- has_session = os.path.exists(self.session_path)
21
- storage = self.session_path if has_session else None
22
-
23
- context = browser.new_context(
24
- viewport={'width': 1280, 'height': 800},
25
- storage_state=storage,
26
- user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
27
- )
28
- page = context.new_page()
29
-
30
- # 2. Definição da URL inicial
31
- if has_session:
32
- print("[API] Tentando restaurar sessão anterior...")
33
- target_url = "https://www.ctfs.com/content/dash/en/private/Summary.html"
34
- else:
35
- print("[API] Iniciando novo login...")
36
- target_url = "https://www.ctfs.com/content/dashpublic/en/login.html"
37
-
38
- page.goto(target_url, wait_until="load", timeout=60000)
39
-
40
- # 3. Lidar com Banner de Cookies (OneTrust)
41
- # Esse banner bloqueia a interação com os campos de texto se não for fechado
42
- try:
43
- cookie_btn = page.locator("#onetrust-accept-btn-handler, text='Accept All'")
44
- if cookie_btn.is_visible(timeout=8000):
45
- cookie_btn.click()
46
- print("[API] Banner de cookies aceito.")
47
- time.sleep(1) # Pausa para animação de fechamento
48
- except:
49
- pass
50
-
51
- # 4. Fluxo de Autenticação (Caso a sessão tenha expirado ou não exista)
52
- if "login" in page.url.lower() or not has_session:
53
- if not self.username or not self.password:
54
- print("[API] Credenciais necessárias para continuar.")
55
- browser.close()
56
- return "NEEDS_CREDENTIALS"
57
-
58
- print("[API] Tela de login detectada. Preenchendo credenciais...")
59
- try:
60
- page.wait_for_selector("#username", timeout=30000)
61
- page.fill("#username", self.username)
62
- page.fill("#password", self.password)
63
-
64
- # Submissão via Teclado (Enter) é mais resiliente contra widgets de chat
65
- page.focus("#password")
66
- page.keyboard.press("Enter")
67
-
68
- # Clique de segurança via JavaScript caso o Enter falhe
69
- page.evaluate("document.querySelector('#signin-form')?.click()")
70
-
71
- # Espera sair da tela de login (mudar URL ou detectar MFA)
72
- print("[API] Formulário enviado. Aguardando resposta do banco...")
73
- page.wait_for_function("() => !window.location.href.includes('login')", timeout=45000)
74
-
75
- # --- Verificação de MFA (SMS/OTP) ---
76
- if "challenge" in page.url or "otp" in page.url or page.is_visible("input[aria-describedby*='otp']"):
77
- print("\n[MFA] Verificação de segurança detectada.")
78
- sms_code = input("Digite o código SMS recebido no celular: ")
79
- page.get_by_role("textbox").first.fill(sms_code)
80
- page.keyboard.press("Enter")
81
- page.wait_for_url("**/Summary.html", timeout=60000)
82
-
83
- print("[API] Login realizado com sucesso!")
84
-
85
- except Exception as e:
86
- print(f"[ERRO] Falha no processo de login: {e}")
87
- page.screenshot(path="debug_login_error.png")
88
- browser.close()
89
- return None
90
-
91
- # 5. Captura Final de Dados (Saldo e transientReference)
92
- # Fazemos um reload forçado para "pescar" o JSON do saldo atualizado
93
- print("[API] Capturando dados financeiros (retrieveAccount)...")
94
- try:
95
- # Garantimos que a página está estável antes de pedir o saldo
96
- page.wait_for_load_state("networkidle", timeout=10000)
97
-
98
- # Tentamos capturar a resposta disparando um reload limpo
99
- with page.expect_response("**/retrieveAccount", timeout=20000) as resp_info:
100
- page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html")
101
-
102
- self.account_data = resp_info.value.json()
103
- if self.account_data:
104
- context.storage_state(path=self.session_path)
105
- print("[API] Sessão e dados financeiros salvos.")
106
- except Exception as e:
107
- # Se falhar aqui, não quebramos o script, pois o get_transactions
108
- # vai tentar sincronizar novamente mais tarde.
109
- print(f"[AVISO] Falha temporária na captura do saldo: {e}")
110
-
111
- browser.close()
112
- return self.account_data
113
-
114
- def get_normalized_transactions(self, start_date=None, headless=True):
115
- """
116
- Versão ultra-robusta para evitar erros de ordenação (NoneType).
117
- """
118
- # 1. Busca as transações brutas
119
- raw_txs = self.get_transactions(start_date=start_date, headless=headless)
120
-
121
- normalized = []
122
- seen_ids = set()
123
-
124
- for tx in raw_txs:
125
- # Pegamos a data. Se não existir, ignoramos o item.
126
- t_date = tx.get("tranDate")
127
- if not t_date:
128
- continue
129
-
130
- payee = tx.get("merchant", "Unknown Merchant")
131
- amount = float(tx.get("amount", 0.0))
132
-
133
- # Gerar ID Único para deduplicação
134
- identifier = f"{t_date}|{payee}|{amount}"
135
- if identifier in seen_ids:
136
- continue
137
- seen_ids.add(identifier)
138
-
139
- # MD5 para o Lunch Money
140
- ext_id = hashlib.md5(identifier.encode("utf-8")).hexdigest()
141
-
142
- # Certifique-se de que o dicionário normalized tenha a chave 'notes'
143
- normalized.append({
144
- "date": t_date,
145
- "payee": payee,
146
- "amount": amount,
147
- "currency": "cad",
148
- "external_id": ext_id,
149
- "type": tx.get("type", "PURCHASE"),
150
- "status": "cleared" if tx.get("category") in ["POSTED", "STATEMENTED"] else "uncleared",
151
- "notes": f"Type: {tx.get('type')} | Imported via Triangle API"
152
- })
153
-
154
- # Ordenar garantindo que não existam Nones na chave de ordenação
155
- return sorted(normalized, key=lambda x: x.get("date", ""), reverse=True)
156
-
157
- def get_transactions(self, start_date=None, headless=True):
158
- today = datetime.today()
159
- if not start_date:
160
- start_date = today.replace(day=1).strftime('%Y-%m-%d')
161
- end_date = today.strftime('%Y-%m-%d')
162
-
163
- print(f"[API] Buscando transações de {start_date} até {end_date}...")
164
-
165
- with sync_playwright() as p:
166
- browser = p.chromium.launch(headless=headless)
167
- storage = self.session_path if os.path.exists(self.session_path) else None
168
- context = browser.new_context(storage_state=storage)
169
- page = context.new_page()
170
-
171
- try:
172
- print("[API] Sincronizando sessão para histórico...")
173
- # Navega e pesca o retrieveAccount da janela atual
174
- with page.expect_response("**/retrieveAccount", timeout=30000) as resp_acc:
175
- page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", wait_until="domcontentloaded")
176
-
177
- acc_info = resp_acc.value.json()
178
- transient = acc_info.get("transientReference")
179
- acc_id = acc_info.get("accountId")
180
- last_stmt = acc_info.get("lastStatementDate")
181
-
182
- cookies = context.cookies()
183
- csrf = next((c['value'] for c in cookies if c['name'] == 'csrftoken'), "")
184
-
185
- # Script de busca tripla
186
- js_script = """
187
- async (params) => {
188
- const call = async (cat, stmt = null) => {
189
- const body = { category: cat, transientReference: params.transient, accountId: params.accId };
190
- if (stmt) body.statementDate = stmt;
191
- const r = await fetch('/dash/v1/account/retrieveTransactions', {
192
- method: 'POST',
193
- headers: { 'Content-Type': 'application/json', 'CSRF-Token': params.csrf, 'csrfToken': params.csrf },
194
- body: JSON.stringify(body)
195
- });
196
- return r.ok ? await r.json() : { transactions: [] };
197
- };
198
- const auth = await call('AUTHORIZED');
199
- const post = await call('POSTED');
200
- const stmt = params.lastStmt ? await call('STATEMENTED', params.lastStmt) : { transactions: [] };
201
- return [...(auth.transactions || []), ...(post.transactions || []), ...(stmt.transactions || [])];
202
- }
203
- """
204
-
205
- raw_txs = page.evaluate(js_script, {
206
- "transient": transient, "accId": acc_id, "lastStmt": last_stmt, "csrf": csrf
207
- })
208
-
209
- # Filtro de data bruto
210
- filtered = [t for t in raw_txs if t.get("tranDate", "") >= start_date]
211
- browser.close()
212
- return filtered
213
-
214
- except Exception as e:
215
- print(f"[ERRO] Falha na captura: {e}")
216
- browser.close()
217
- return []
218
-
219
- def get_balance(self):
220
- return self.account_data.get("currentBalanceAmt", 0.0) if self.account_data else "N/A"
File without changes
File without changes